Compare commits
17 Commits
711b3ec63b
...
fd9e526b81
| Author | SHA1 | Date | |
|---|---|---|---|
| fd9e526b81 | |||
| 6dd4e3366c | |||
| 48b57abf92 | |||
| 686dc1c91a | |||
| 693f53b4b6 | |||
| a245b7b8b9 | |||
| 988f5c75aa | |||
| 3f6b91c943 | |||
| c76894e527 | |||
| 1a77e15d70 | |||
| f697267828 | |||
| 5a64dd361c | |||
| 189901b778 | |||
| 8ed7f3d5bc | |||
| a902154777 | |||
| e935c8973e | |||
| bf3e336d0f |
@@ -74,6 +74,7 @@ fn thought_note_json(
|
|||||||
thought: &domain::models::thought::Thought,
|
thought: &domain::models::thought::Thought,
|
||||||
local_actor: &crate::actors::DbActor,
|
local_actor: &crate::actors::DbActor,
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
|
in_reply_to_url: Option<&str>,
|
||||||
) -> anyhow::Result<(url::Url, serde_json::Value)> {
|
) -> anyhow::Result<(url::Url, serde_json::Value)> {
|
||||||
let ap_id = url::Url::parse(&format!("{}/thoughts/{}", base_url, thought.id))?;
|
let ap_id = url::Url::parse(&format!("{}/thoughts/{}", base_url, thought.id))?;
|
||||||
|
|
||||||
@@ -107,7 +108,7 @@ fn thought_note_json(
|
|||||||
if let Some(ref cw) = thought.content_warning {
|
if let Some(ref cw) = thought.content_warning {
|
||||||
note["summary"] = serde_json::json!(cw);
|
note["summary"] = serde_json::json!(cw);
|
||||||
}
|
}
|
||||||
if let Some(ref reply_url) = thought.in_reply_to_url {
|
if let Some(reply_url) = in_reply_to_url {
|
||||||
note["inReplyTo"] = serde_json::json!(reply_url);
|
note["inReplyTo"] = serde_json::json!(reply_url);
|
||||||
}
|
}
|
||||||
if let Some(updated_at) = thought.updated_at {
|
if let Some(updated_at) = thought.updated_at {
|
||||||
@@ -1301,11 +1302,8 @@ impl ActivityPubService {
|
|||||||
url: a.url,
|
url: a.url,
|
||||||
handle: a.handle,
|
handle: a.handle,
|
||||||
display_name: a.display_name,
|
display_name: a.display_name,
|
||||||
inbox_url: a.inbox_url,
|
|
||||||
shared_inbox_url: a.shared_inbox_url,
|
|
||||||
avatar_url: a.avatar_url,
|
avatar_url: a.avatar_url,
|
||||||
outbox_url: a.outbox_url,
|
outbox_url: a.outbox_url,
|
||||||
public_key: String::new(),
|
|
||||||
last_fetched_at: chrono::Utc::now(),
|
last_fetched_at: chrono::Utc::now(),
|
||||||
bio: None,
|
bio: None,
|
||||||
banner_url: None,
|
banner_url: None,
|
||||||
@@ -1415,6 +1413,7 @@ impl domain::ports::OutboundFederationPort for ActivityPubService {
|
|||||||
author_user_id: &domain::value_objects::UserId,
|
author_user_id: &domain::value_objects::UserId,
|
||||||
thought: &domain::models::thought::Thought,
|
thought: &domain::models::thought::Thought,
|
||||||
_author_username: &str,
|
_author_username: &str,
|
||||||
|
in_reply_to_url: Option<&str>,
|
||||||
) -> Result<(), domain::errors::DomainError> {
|
) -> Result<(), domain::errors::DomainError> {
|
||||||
let user_uuid = author_user_id.as_uuid();
|
let user_uuid = author_user_id.as_uuid();
|
||||||
let data = self.federation_config.to_request_data();
|
let data = self.federation_config.to_request_data();
|
||||||
@@ -1426,7 +1425,8 @@ impl domain::ports::OutboundFederationPort for ActivityPubService {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
let (ap_id, note) = thought_note_json(thought, &local_actor, &self.base_url)
|
let (ap_id, note) =
|
||||||
|
thought_note_json(thought, &local_actor, &self.base_url, in_reply_to_url)
|
||||||
.map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?;
|
.map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
let create = crate::activities::CreateActivity {
|
let create = crate::activities::CreateActivity {
|
||||||
@@ -1476,6 +1476,7 @@ impl domain::ports::OutboundFederationPort for ActivityPubService {
|
|||||||
author_user_id: &domain::value_objects::UserId,
|
author_user_id: &domain::value_objects::UserId,
|
||||||
thought: &domain::models::thought::Thought,
|
thought: &domain::models::thought::Thought,
|
||||||
_author_username: &str,
|
_author_username: &str,
|
||||||
|
in_reply_to_url: Option<&str>,
|
||||||
) -> Result<(), domain::errors::DomainError> {
|
) -> Result<(), domain::errors::DomainError> {
|
||||||
let user_uuid = author_user_id.as_uuid();
|
let user_uuid = author_user_id.as_uuid();
|
||||||
let data = self.federation_config.to_request_data();
|
let data = self.federation_config.to_request_data();
|
||||||
@@ -1487,7 +1488,8 @@ impl domain::ports::OutboundFederationPort for ActivityPubService {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
let (_ap_id, note) = thought_note_json(thought, &local_actor, &self.base_url)
|
let (_ap_id, note) =
|
||||||
|
thought_note_json(thought, &local_actor, &self.base_url, in_reply_to_url)
|
||||||
.map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?;
|
.map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
let update_id = url::Url::parse(&format!(
|
let update_id = url::Url::parse(&format!(
|
||||||
@@ -1588,7 +1590,40 @@ impl domain::ports::OutboundFederationPort for ActivityPubService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl domain::ports::FederationActionPort for ActivityPubService {
|
impl domain::ports::FederationSchedulerPort for ActivityPubService {
|
||||||
|
async fn schedule_actor_posts_fetch(
|
||||||
|
&self,
|
||||||
|
actor_ap_url: &str,
|
||||||
|
outbox_url: &str,
|
||||||
|
) -> Result<(), domain::errors::DomainError> {
|
||||||
|
tracing::debug!(
|
||||||
|
actor = actor_ap_url,
|
||||||
|
outbox = outbox_url,
|
||||||
|
"schedule_actor_posts_fetch: deferred"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn schedule_connections_fetch(
|
||||||
|
&self,
|
||||||
|
actor_ap_url: &str,
|
||||||
|
collection_url: &str,
|
||||||
|
connection_type: &str,
|
||||||
|
page: u32,
|
||||||
|
) -> Result<(), domain::errors::DomainError> {
|
||||||
|
tracing::debug!(
|
||||||
|
actor = actor_ap_url,
|
||||||
|
collection = collection_url,
|
||||||
|
connection_type,
|
||||||
|
page,
|
||||||
|
"schedule_connections_fetch: deferred"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl domain::ports::FederationLookupPort for ActivityPubService {
|
||||||
async fn lookup_actor(
|
async fn lookup_actor(
|
||||||
&self,
|
&self,
|
||||||
handle: &str,
|
handle: &str,
|
||||||
@@ -1646,9 +1681,6 @@ impl domain::ports::FederationActionPort for ActivityPubService {
|
|||||||
url: actor.ap_id.to_string(),
|
url: actor.ap_id.to_string(),
|
||||||
handle: full_handle,
|
handle: full_handle,
|
||||||
display_name: Some(actor.username.clone()),
|
display_name: Some(actor.username.clone()),
|
||||||
inbox_url: actor.inbox_url.to_string(),
|
|
||||||
shared_inbox_url: actor.shared_inbox_url.as_ref().map(|u| u.to_string()),
|
|
||||||
public_key: actor.public_key_pem.clone(),
|
|
||||||
avatar_url: actor.avatar_url.as_ref().map(|u| u.to_string()),
|
avatar_url: actor.avatar_url.as_ref().map(|u| u.to_string()),
|
||||||
last_fetched_at: actor.last_refreshed_at,
|
last_fetched_at: actor.last_refreshed_at,
|
||||||
bio: actor.bio.clone(),
|
bio: actor.bio.clone(),
|
||||||
@@ -1665,31 +1697,6 @@ impl domain::ports::FederationActionPort for ActivityPubService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn follow_remote(
|
|
||||||
&self,
|
|
||||||
local_user_id: &domain::value_objects::UserId,
|
|
||||||
handle: &str,
|
|
||||||
) -> Result<(), domain::errors::DomainError> {
|
|
||||||
self.follow(local_user_id.as_uuid(), handle)
|
|
||||||
.await
|
|
||||||
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn unfollow_remote(
|
|
||||||
&self,
|
|
||||||
local_user_id: &domain::value_objects::UserId,
|
|
||||||
handle: &str,
|
|
||||||
) -> Result<(), domain::errors::DomainError> {
|
|
||||||
let data = self.federation_config.to_request_data();
|
|
||||||
let remote_actor: DbActor = Self::webfinger_https(handle, &data)
|
|
||||||
.await
|
|
||||||
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?;
|
|
||||||
let actor_url = remote_actor.ap_id.to_string();
|
|
||||||
self.unfollow(local_user_id.as_uuid(), &actor_url)
|
|
||||||
.await
|
|
||||||
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn actor_json(
|
async fn actor_json(
|
||||||
&self,
|
&self,
|
||||||
user_id: &domain::value_objects::UserId,
|
user_id: &domain::value_objects::UserId,
|
||||||
@@ -1794,7 +1801,10 @@ impl domain::ports::FederationActionPort for ActivityPubService {
|
|||||||
serde_json::to_string(&obj)
|
serde_json::to_string(&obj)
|
||||||
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
|
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl domain::ports::FederationFetchPort for ActivityPubService {
|
||||||
async fn fetch_outbox_page(
|
async fn fetch_outbox_page(
|
||||||
&self,
|
&self,
|
||||||
outbox_url: &str,
|
outbox_url: &str,
|
||||||
@@ -1988,7 +1998,48 @@ impl domain::ports::FederationActionPort for ActivityPubService {
|
|||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl domain::ports::FederationFollowPort for ActivityPubService {
|
||||||
|
async fn follow_remote(
|
||||||
|
&self,
|
||||||
|
local_user_id: &domain::value_objects::UserId,
|
||||||
|
handle: &str,
|
||||||
|
) -> Result<(), domain::errors::DomainError> {
|
||||||
|
self.follow(local_user_id.as_uuid(), handle)
|
||||||
|
.await
|
||||||
|
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn unfollow_remote(
|
||||||
|
&self,
|
||||||
|
local_user_id: &domain::value_objects::UserId,
|
||||||
|
handle: &str,
|
||||||
|
) -> Result<(), domain::errors::DomainError> {
|
||||||
|
let data = self.federation_config.to_request_data();
|
||||||
|
let remote_actor: DbActor = Self::webfinger_https(handle, &data)
|
||||||
|
.await
|
||||||
|
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?;
|
||||||
|
let actor_url = remote_actor.ap_id.to_string();
|
||||||
|
self.unfollow(local_user_id.as_uuid(), &actor_url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_remote_following(
|
||||||
|
&self,
|
||||||
|
user_id: &domain::value_objects::UserId,
|
||||||
|
) -> Result<Vec<domain::models::remote_actor::RemoteActor>, domain::errors::DomainError> {
|
||||||
|
self.get_following(user_id.as_uuid())
|
||||||
|
.await
|
||||||
|
.map(|v| v.into_iter().map(Self::adapter_actor_to_domain).collect())
|
||||||
|
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl domain::ports::FederationFollowRequestPort for ActivityPubService {
|
||||||
async fn get_pending_followers(
|
async fn get_pending_followers(
|
||||||
&self,
|
&self,
|
||||||
user_id: &domain::value_objects::UserId,
|
user_id: &domain::value_objects::UserId,
|
||||||
@@ -2038,16 +2089,6 @@ impl domain::ports::FederationActionPort for ActivityPubService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
|
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_remote_following(
|
|
||||||
&self,
|
|
||||||
user_id: &domain::value_objects::UserId,
|
|
||||||
) -> Result<Vec<domain::models::remote_actor::RemoteActor>, domain::errors::DomainError> {
|
|
||||||
self.get_following(user_id.as_uuid())
|
|
||||||
.await
|
|
||||||
.map(|v| v.into_iter().map(Self::adapter_actor_to_domain).collect())
|
|
||||||
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,10 +1,28 @@
|
|||||||
fn _assert_impl_federation_action_port()
|
fn _assert_impl_federation_lookup_port()
|
||||||
where
|
where
|
||||||
crate::service::ActivityPubService: domain::ports::FederationActionPort,
|
crate::service::ActivityPubService: domain::ports::FederationLookupPort,
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
fn _assert_impl_federation_action_port_connections()
|
fn _assert_impl_federation_follow_port()
|
||||||
|
where
|
||||||
|
crate::service::ActivityPubService: domain::ports::FederationFollowPort,
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
fn _assert_impl_federation_follow_request_port()
|
||||||
|
where
|
||||||
|
crate::service::ActivityPubService: domain::ports::FederationFollowRequestPort,
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
fn _assert_impl_federation_fetch_port()
|
||||||
|
where
|
||||||
|
crate::service::ActivityPubService: domain::ports::FederationFetchPort,
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
fn _assert_impl_federation_action_port()
|
||||||
where
|
where
|
||||||
crate::service::ActivityPubService: domain::ports::FederationActionPort,
|
crate::service::ActivityPubService: domain::ports::FederationActionPort,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
|||||||
let note: ThoughtNote = serde_json::from_value(object)?;
|
let note: ThoughtNote = serde_json::from_value(object)?;
|
||||||
let author_id = self
|
let author_id = self
|
||||||
.repo
|
.repo
|
||||||
.intern_remote_actor(actor_url)
|
.intern_remote_actor(actor_url.as_str())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
.map_err(|e| anyhow!("{e}"))?;
|
||||||
|
|
||||||
@@ -140,14 +140,14 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
|||||||
|
|
||||||
self.repo
|
self.repo
|
||||||
.accept_note(
|
.accept_note(
|
||||||
ap_id,
|
ap_id.as_str(),
|
||||||
&author_id,
|
&author_id,
|
||||||
¬e.content,
|
¬e.content,
|
||||||
note.published,
|
note.published,
|
||||||
note.sensitive,
|
note.sensitive,
|
||||||
note.summary,
|
note.summary,
|
||||||
visibility,
|
visibility,
|
||||||
note.in_reply_to.as_ref(),
|
note.in_reply_to.as_ref().map(|u| u.as_str()),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
.map_err(|e| anyhow!("{e}"))?;
|
||||||
@@ -198,21 +198,21 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let note: ThoughtNote = serde_json::from_value(object)?;
|
let note: ThoughtNote = serde_json::from_value(object)?;
|
||||||
self.repo
|
self.repo
|
||||||
.apply_note_update(ap_id, ¬e.content)
|
.apply_note_update(ap_id.as_str(), ¬e.content)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("{e}"))
|
.map_err(|e| anyhow!("{e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_delete(&self, ap_id: &Url, _actor_url: &Url) -> Result<()> {
|
async fn on_delete(&self, ap_id: &Url, _actor_url: &Url) -> Result<()> {
|
||||||
self.repo
|
self.repo
|
||||||
.retract_note(ap_id)
|
.retract_note(ap_id.as_str())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("{e}"))
|
.map_err(|e| anyhow!("{e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_actor_removed(&self, actor_url: &Url) -> Result<()> {
|
async fn on_actor_removed(&self, actor_url: &Url) -> Result<()> {
|
||||||
self.repo
|
self.repo
|
||||||
.retract_actor_notes(actor_url)
|
.retract_actor_notes(actor_url.as_str())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("{e}"))
|
.map_err(|e| anyhow!("{e}"))
|
||||||
}
|
}
|
||||||
@@ -234,7 +234,7 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
|||||||
|
|
||||||
let actor_user_id = self
|
let actor_user_id = self
|
||||||
.repo
|
.repo
|
||||||
.find_remote_actor_id(actor_url)
|
.find_remote_actor_id(actor_url.as_str())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
.map_err(|e| anyhow!("{e}"))?;
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
|||||||
|
|
||||||
let actor_user_id = self
|
let actor_user_id = self
|
||||||
.repo
|
.repo
|
||||||
.find_remote_actor_id(actor_url)
|
.find_remote_actor_id(actor_url.as_str())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
.map_err(|e| anyhow!("{e}"))?;
|
||||||
|
|
||||||
@@ -310,7 +310,7 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
|||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let author_user_id = match self
|
let author_user_id = match self
|
||||||
.repo
|
.repo
|
||||||
.find_remote_actor_id(actor_url)
|
.find_remote_actor_id(actor_url.as_str())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("{e}"))?
|
.map_err(|e| anyhow!("{e}"))?
|
||||||
{
|
{
|
||||||
@@ -356,7 +356,7 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
|||||||
|
|
||||||
let actor_user_id = self
|
let actor_user_id = self
|
||||||
.repo
|
.repo
|
||||||
.find_remote_actor_id(actor_url)
|
.find_remote_actor_id(actor_url.as_str())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("{e}"))?;
|
.map_err(|e| anyhow!("{e}"))?;
|
||||||
|
|
||||||
|
|||||||
@@ -71,16 +71,6 @@ pub enum EventPayload {
|
|||||||
ProfileUpdated {
|
ProfileUpdated {
|
||||||
user_id: String,
|
user_id: String,
|
||||||
},
|
},
|
||||||
FetchRemoteActorPosts {
|
|
||||||
actor_ap_url: String,
|
|
||||||
outbox_url: String,
|
|
||||||
},
|
|
||||||
FetchActorConnections {
|
|
||||||
actor_ap_url: String,
|
|
||||||
collection_url: String,
|
|
||||||
connection_type: String,
|
|
||||||
page: u32,
|
|
||||||
},
|
|
||||||
MentionReceived {
|
MentionReceived {
|
||||||
thought_id: String,
|
thought_id: String,
|
||||||
mentioned_user_id: String,
|
mentioned_user_id: String,
|
||||||
@@ -107,8 +97,6 @@ impl EventPayload {
|
|||||||
Self::UserUnblocked { .. } => "users.unblocked",
|
Self::UserUnblocked { .. } => "users.unblocked",
|
||||||
Self::UserRegistered { .. } => "users.registered",
|
Self::UserRegistered { .. } => "users.registered",
|
||||||
Self::ProfileUpdated { .. } => "users.profile_updated",
|
Self::ProfileUpdated { .. } => "users.profile_updated",
|
||||||
Self::FetchRemoteActorPosts { .. } => "federation.fetch_outbox",
|
|
||||||
Self::FetchActorConnections { .. } => "federation.fetch_connections",
|
|
||||||
Self::MentionReceived { .. } => "mentions.received",
|
Self::MentionReceived { .. } => "mentions.received",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,24 +210,6 @@ impl From<&DomainEvent> for EventPayload {
|
|||||||
DomainEvent::ProfileUpdated { user_id } => Self::ProfileUpdated {
|
DomainEvent::ProfileUpdated { user_id } => Self::ProfileUpdated {
|
||||||
user_id: user_id.to_string(),
|
user_id: user_id.to_string(),
|
||||||
},
|
},
|
||||||
DomainEvent::FetchRemoteActorPosts {
|
|
||||||
actor_ap_url,
|
|
||||||
outbox_url,
|
|
||||||
} => Self::FetchRemoteActorPosts {
|
|
||||||
actor_ap_url: actor_ap_url.clone(),
|
|
||||||
outbox_url: outbox_url.clone(),
|
|
||||||
},
|
|
||||||
DomainEvent::FetchActorConnections {
|
|
||||||
actor_ap_url,
|
|
||||||
collection_url,
|
|
||||||
connection_type,
|
|
||||||
page,
|
|
||||||
} => Self::FetchActorConnections {
|
|
||||||
actor_ap_url: actor_ap_url.clone(),
|
|
||||||
collection_url: collection_url.clone(),
|
|
||||||
connection_type: connection_type.clone(),
|
|
||||||
page: *page,
|
|
||||||
},
|
|
||||||
DomainEvent::MentionReceived {
|
DomainEvent::MentionReceived {
|
||||||
thought_id,
|
thought_id,
|
||||||
mentioned_user_id,
|
mentioned_user_id,
|
||||||
@@ -370,24 +340,6 @@ impl TryFrom<EventPayload> for DomainEvent {
|
|||||||
EventPayload::ProfileUpdated { user_id } => DomainEvent::ProfileUpdated {
|
EventPayload::ProfileUpdated { user_id } => DomainEvent::ProfileUpdated {
|
||||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||||
},
|
},
|
||||||
EventPayload::FetchRemoteActorPosts {
|
|
||||||
actor_ap_url,
|
|
||||||
outbox_url,
|
|
||||||
} => DomainEvent::FetchRemoteActorPosts {
|
|
||||||
actor_ap_url,
|
|
||||||
outbox_url,
|
|
||||||
},
|
|
||||||
EventPayload::FetchActorConnections {
|
|
||||||
actor_ap_url,
|
|
||||||
collection_url,
|
|
||||||
connection_type,
|
|
||||||
page,
|
|
||||||
} => DomainEvent::FetchActorConnections {
|
|
||||||
actor_ap_url,
|
|
||||||
collection_url,
|
|
||||||
connection_type,
|
|
||||||
page,
|
|
||||||
},
|
|
||||||
EventPayload::MentionReceived {
|
EventPayload::MentionReceived {
|
||||||
thought_id,
|
thought_id,
|
||||||
mentioned_user_id,
|
mentioned_user_id,
|
||||||
@@ -481,16 +433,6 @@ mod tests {
|
|||||||
EventPayload::UserRegistered {
|
EventPayload::UserRegistered {
|
||||||
user_id: "a".into(),
|
user_id: "a".into(),
|
||||||
},
|
},
|
||||||
EventPayload::FetchRemoteActorPosts {
|
|
||||||
actor_ap_url: "https://mastodon.social/users/alice".into(),
|
|
||||||
outbox_url: "https://mastodon.social/users/alice/outbox".into(),
|
|
||||||
},
|
|
||||||
EventPayload::FetchActorConnections {
|
|
||||||
actor_ap_url: "https://mastodon.social/users/alice".into(),
|
|
||||||
collection_url: "https://mastodon.social/users/alice/followers".into(),
|
|
||||||
connection_type: "followers".into(),
|
|
||||||
page: 1,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
let mut subjects: Vec<&str> = samples.iter().map(|p| p.subject()).collect();
|
let mut subjects: Vec<&str> = samples.iter().map(|p| p.subject()).collect();
|
||||||
subjects.sort();
|
subjects.sort();
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ struct FeedRow {
|
|||||||
t_user_id: uuid::Uuid,
|
t_user_id: uuid::Uuid,
|
||||||
content: String,
|
content: String,
|
||||||
in_reply_to_id: Option<uuid::Uuid>,
|
in_reply_to_id: Option<uuid::Uuid>,
|
||||||
in_reply_to_url: Option<String>,
|
|
||||||
t_ap_id: Option<String>,
|
|
||||||
visibility: String,
|
visibility: String,
|
||||||
content_warning: Option<String>,
|
content_warning: Option<String>,
|
||||||
sensitive: bool,
|
sensitive: bool,
|
||||||
@@ -47,39 +45,48 @@ struct FeedRow {
|
|||||||
header_url: Option<String>,
|
header_url: Option<String>,
|
||||||
custom_css: Option<String>,
|
custom_css: Option<String>,
|
||||||
author_local: bool,
|
author_local: bool,
|
||||||
u_ap_id: Option<String>,
|
|
||||||
inbox_url: Option<String>,
|
|
||||||
author_created_at: DateTime<Utc>,
|
author_created_at: DateTime<Utc>,
|
||||||
author_updated_at: DateTime<Utc>,
|
author_updated_at: DateTime<Utc>,
|
||||||
like_count: i64,
|
like_count: i64,
|
||||||
boost_count: i64,
|
boost_count: i64,
|
||||||
reply_count: i64,
|
reply_count: i64,
|
||||||
|
liked_by_viewer: bool,
|
||||||
|
boosted_by_viewer: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
const FEED_SELECT: &str = "
|
fn feed_select(viewer: Option<uuid::Uuid>) -> String {
|
||||||
SELECT
|
let viewer_checks = match viewer {
|
||||||
t.id AS thought_id, t.user_id AS t_user_id, t.content,
|
Some(uid) => format!(
|
||||||
t.in_reply_to_id, t.in_reply_to_url, t.ap_id AS t_ap_id,
|
"EXISTS(SELECT 1 FROM likes WHERE user_id='{uid}' AND thought_id=t.id) AS liked_by_viewer,\n\
|
||||||
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,
|
EXISTS(SELECT 1 FROM boosts WHERE user_id='{uid}' AND thought_id=t.id) AS boosted_by_viewer"
|
||||||
t.created_at AS thought_created_at, t.updated_at,
|
),
|
||||||
u.id AS author_id, u.username, u.email, u.password_hash,
|
None => "false AS liked_by_viewer, false AS boosted_by_viewer".to_string(),
|
||||||
u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css,
|
};
|
||||||
u.local AS author_local, u.ap_id AS u_ap_id, u.inbox_url,
|
format!(
|
||||||
u.created_at AS author_created_at, u.updated_at AS author_updated_at,
|
"\n SELECT\n\
|
||||||
(SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,
|
t.id AS thought_id, t.user_id AS t_user_id, t.content,\n\
|
||||||
(SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,
|
t.in_reply_to_id,\n\
|
||||||
(SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count
|
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,\n\
|
||||||
FROM thoughts t JOIN users u ON u.id=t.user_id";
|
t.created_at AS thought_created_at, t.updated_at,\n\
|
||||||
|
u.id AS author_id, u.username, u.email, u.password_hash,\n\
|
||||||
|
u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css,\n\
|
||||||
|
u.local AS author_local,\n\
|
||||||
|
u.created_at AS author_created_at, u.updated_at AS author_updated_at,\n\
|
||||||
|
(SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,\n\
|
||||||
|
(SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,\n\
|
||||||
|
(SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count,\n\
|
||||||
|
{viewer_checks}\n\
|
||||||
|
FROM thoughts t JOIN users u ON u.id=t.user_id"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn row_to_entry(r: FeedRow) -> FeedEntry {
|
fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, DomainError> {
|
||||||
let thought = Thought {
|
let thought = Thought {
|
||||||
id: ThoughtId::from_uuid(r.thought_id),
|
id: ThoughtId::from_uuid(r.thought_id),
|
||||||
user_id: UserId::from_uuid(r.t_user_id),
|
user_id: UserId::from_uuid(r.t_user_id),
|
||||||
content: Content::new_remote(r.content),
|
content: Content::new_remote(r.content),
|
||||||
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
|
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
|
||||||
in_reply_to_url: r.in_reply_to_url,
|
visibility: Visibility::from_db_str(&r.visibility)?,
|
||||||
ap_id: r.t_ap_id,
|
|
||||||
visibility: Visibility::from_db_str(&r.visibility),
|
|
||||||
content_warning: r.content_warning,
|
content_warning: r.content_warning,
|
||||||
sensitive: r.sensitive,
|
sensitive: r.sensitive,
|
||||||
local: r.t_local,
|
local: r.t_local,
|
||||||
@@ -97,20 +104,22 @@ fn row_to_entry(r: FeedRow) -> FeedEntry {
|
|||||||
header_url: r.header_url,
|
header_url: r.header_url,
|
||||||
custom_css: r.custom_css,
|
custom_css: r.custom_css,
|
||||||
local: r.author_local,
|
local: r.author_local,
|
||||||
ap_id: r.u_ap_id,
|
|
||||||
inbox_url: r.inbox_url,
|
|
||||||
created_at: r.author_created_at,
|
created_at: r.author_created_at,
|
||||||
updated_at: r.author_updated_at,
|
updated_at: r.author_updated_at,
|
||||||
};
|
};
|
||||||
FeedEntry {
|
Ok(FeedEntry {
|
||||||
thought,
|
thought,
|
||||||
author,
|
author,
|
||||||
|
stats: domain::models::feed::EngagementStats {
|
||||||
like_count: r.like_count,
|
like_count: r.like_count,
|
||||||
boost_count: r.boost_count,
|
boost_count: r.boost_count,
|
||||||
reply_count: r.reply_count,
|
reply_count: r.reply_count,
|
||||||
liked_by_viewer: false,
|
},
|
||||||
boosted_by_viewer: false,
|
viewer: viewer.map(|_| domain::models::feed::ViewerContext {
|
||||||
}
|
liked: r.liked_by_viewer,
|
||||||
|
boosted: r.boosted_by_viewer,
|
||||||
|
}),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -119,8 +128,11 @@ impl SearchPort for PgSearchRepository {
|
|||||||
&self,
|
&self,
|
||||||
query: &str,
|
query: &str,
|
||||||
page: &PageParams,
|
page: &PageParams,
|
||||||
_viewer_id: Option<&UserId>,
|
viewer_id: Option<&UserId>,
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
let viewer = viewer_id.map(|v| v.as_uuid());
|
||||||
|
let select = feed_select(viewer);
|
||||||
|
|
||||||
let total: i64 = sqlx::query_scalar(
|
let total: i64 = sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM thoughts t
|
"SELECT COUNT(*) FROM thoughts t
|
||||||
WHERE t.content % $1 AND t.visibility='public'",
|
WHERE t.content % $1 AND t.visibility='public'",
|
||||||
@@ -131,7 +143,7 @@ impl SearchPort for PgSearchRepository {
|
|||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"{FEED_SELECT}
|
"{select}
|
||||||
WHERE t.content % $1 AND t.visibility='public'
|
WHERE t.content % $1 AND t.visibility='public'
|
||||||
ORDER BY similarity(t.content, $1) DESC
|
ORDER BY similarity(t.content, $1) DESC
|
||||||
LIMIT $2 OFFSET $3"
|
LIMIT $2 OFFSET $3"
|
||||||
@@ -145,7 +157,10 @@ impl SearchPort for PgSearchRepository {
|
|||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
Ok(Paginated {
|
Ok(Paginated {
|
||||||
items: rows.into_iter().map(row_to_entry).collect(),
|
items: rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| row_to_entry(r, viewer))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
total,
|
total,
|
||||||
page: page.page,
|
page: page.page,
|
||||||
per_page: page.per_page,
|
per_page: page.per_page,
|
||||||
@@ -197,7 +212,7 @@ mod tests {
|
|||||||
thought::{Thought, Visibility},
|
thought::{Thought, Visibility},
|
||||||
user::User,
|
user::User,
|
||||||
},
|
},
|
||||||
ports::{SearchPort, ThoughtRepository, UserRepository},
|
ports::{SearchPort, ThoughtRepository, UserWriter},
|
||||||
value_objects::*,
|
value_objects::*,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -291,4 +306,67 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(result.total, 0);
|
assert_eq!(result.total, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "../postgres/migrations")]
|
||||||
|
async fn search_thoughts_viewer_context(pool: sqlx::PgPool) {
|
||||||
|
use domain::models::social::Like;
|
||||||
|
use domain::ports::{LikeRepository, UserWriter};
|
||||||
|
use domain::value_objects::LikeId;
|
||||||
|
use postgres::{like::PgLikeRepository, user::PgUserRepository};
|
||||||
|
|
||||||
|
let (alice, thought) = seed_thought(&pool, "alice", "hello world").await;
|
||||||
|
|
||||||
|
// alice likes her own thought
|
||||||
|
let like_repo = PgLikeRepository::new(pool.clone());
|
||||||
|
like_repo
|
||||||
|
.save(&Like {
|
||||||
|
id: LikeId::new(),
|
||||||
|
user_id: alice.id.clone(),
|
||||||
|
thought_id: thought.id.clone(),
|
||||||
|
ap_id: None,
|
||||||
|
created_at: chrono::Utc::now(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let repo = PgSearchRepository::new(pool);
|
||||||
|
|
||||||
|
// with viewer — should see liked = true
|
||||||
|
let authed = repo
|
||||||
|
.search_thoughts(
|
||||||
|
"hello",
|
||||||
|
&PageParams {
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
},
|
||||||
|
Some(&alice.id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(authed.items.len(), 1);
|
||||||
|
let ctx = authed.items[0]
|
||||||
|
.viewer
|
||||||
|
.as_ref()
|
||||||
|
.expect("viewer context present");
|
||||||
|
assert!(ctx.liked, "alice should see the thought as liked");
|
||||||
|
assert!(!ctx.boosted);
|
||||||
|
|
||||||
|
// without viewer — viewer should be None
|
||||||
|
let anon = repo
|
||||||
|
.search_thoughts(
|
||||||
|
"hello",
|
||||||
|
&PageParams {
|
||||||
|
page: 1,
|
||||||
|
per_page: 20,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(anon.items.len(), 1);
|
||||||
|
assert!(
|
||||||
|
anon.items[0].viewer.is_none(),
|
||||||
|
"anonymous request has no viewer context"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE notifications RENAME COLUMN "type" TO notification_type;
|
||||||
@@ -5,12 +5,11 @@ const MAX_REMOTE_CONTENT_CHARS: usize = 500;
|
|||||||
const THOUGHTS_PATH_PREFIX: &str = "/thoughts/";
|
const THOUGHTS_PATH_PREFIX: &str = "/thoughts/";
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::thought::{Thought, Visibility},
|
models::thought::{Thought, Visibility},
|
||||||
ports::{ActivityPubRepository, OutboxEntry},
|
ports::{ActivityPubRepository, ActorApUrls, OutboxEntry},
|
||||||
value_objects::{Content, ThoughtId, UserId, Username},
|
value_objects::{Content, ThoughtId, UserId, Username},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,8 +59,6 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
|||||||
user_id: UserId::from_uuid(r.user_id),
|
user_id: UserId::from_uuid(r.user_id),
|
||||||
content: Content::new_remote(r.content),
|
content: Content::new_remote(r.content),
|
||||||
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
|
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
|
||||||
in_reply_to_url: None,
|
|
||||||
ap_id: None,
|
|
||||||
visibility: Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
content_warning: r.content_warning,
|
content_warning: r.content_warning,
|
||||||
sensitive: r.sensitive,
|
sensitive: r.sensitive,
|
||||||
@@ -127,8 +124,6 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
|||||||
user_id: UserId::from_uuid(r.user_id),
|
user_id: UserId::from_uuid(r.user_id),
|
||||||
content: Content::new_remote(r.content),
|
content: Content::new_remote(r.content),
|
||||||
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
|
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
|
||||||
in_reply_to_url: None,
|
|
||||||
ap_id: None,
|
|
||||||
visibility: Visibility::Public,
|
visibility: Visibility::Public,
|
||||||
content_warning: r.content_warning,
|
content_warning: r.content_warning,
|
||||||
sensitive: r.sensitive,
|
sensitive: r.sensitive,
|
||||||
@@ -143,17 +138,17 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
|||||||
|
|
||||||
async fn find_remote_actor_id(
|
async fn find_remote_actor_id(
|
||||||
&self,
|
&self,
|
||||||
actor_ap_url: &Url,
|
actor_ap_url: &str,
|
||||||
) -> Result<Option<UserId>, DomainError> {
|
) -> Result<Option<UserId>, DomainError> {
|
||||||
sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM users WHERE ap_id=$1")
|
sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM users WHERE ap_id=$1")
|
||||||
.bind(actor_ap_url.as_str())
|
.bind(actor_ap_url)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()
|
.into_domain()
|
||||||
.map(|o| o.map(UserId::from_uuid))
|
.map(|o| o.map(UserId::from_uuid))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn intern_remote_actor(&self, actor_ap_url: &Url) -> Result<UserId, DomainError> {
|
async fn intern_remote_actor(&self, actor_ap_url: &str) -> Result<UserId, DomainError> {
|
||||||
if let Some(id) = self.find_remote_actor_id(actor_ap_url).await? {
|
if let Some(id) = self.find_remote_actor_id(actor_ap_url).await? {
|
||||||
return Ok(id);
|
return Ok(id);
|
||||||
}
|
}
|
||||||
@@ -161,11 +156,13 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
|||||||
// Use the last path segment as username (e.g. /users/alice → "alice").
|
// Use the last path segment as username (e.g. /users/alice → "alice").
|
||||||
// Falls back to a random short id for long segments (e.g. UUID-based actor URLs).
|
// Falls back to a random short id for long segments (e.g. UUID-based actor URLs).
|
||||||
// username column is VARCHAR(32).
|
// username column is VARCHAR(32).
|
||||||
let last_seg = actor_ap_url
|
let last_seg = url::Url::parse(actor_ap_url)
|
||||||
.path_segments()
|
.ok()
|
||||||
.and_then(|mut s| s.next_back())
|
.and_then(|u| {
|
||||||
.unwrap_or("")
|
u.path_segments()
|
||||||
.to_string();
|
.and_then(|mut s| s.next_back().map(|s| s.to_string()))
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
let handle = if last_seg.is_empty() {
|
let handle = if last_seg.is_empty() {
|
||||||
format!("remote_{}", &new_id.to_string()[..13])
|
format!("remote_{}", &new_id.to_string()[..13])
|
||||||
} else if last_seg.len() <= 32 {
|
} else if last_seg.len() <= 32 {
|
||||||
@@ -180,7 +177,7 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
|||||||
.bind(new_id)
|
.bind(new_id)
|
||||||
.bind(&handle)
|
.bind(&handle)
|
||||||
.bind(format!("{}@remote", new_id))
|
.bind(format!("{}@remote", new_id))
|
||||||
.bind(actor_ap_url.as_str())
|
.bind(actor_ap_url)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
@@ -215,25 +212,26 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
|||||||
|
|
||||||
async fn accept_note(
|
async fn accept_note(
|
||||||
&self,
|
&self,
|
||||||
ap_id: &Url,
|
ap_id: &str,
|
||||||
author_id: &UserId,
|
author_id: &UserId,
|
||||||
content: &str,
|
content: &str,
|
||||||
published: DateTime<Utc>,
|
published: DateTime<Utc>,
|
||||||
sensitive: bool,
|
sensitive: bool,
|
||||||
content_warning: Option<String>,
|
content_warning: Option<String>,
|
||||||
visibility: &str,
|
visibility: &str,
|
||||||
in_reply_to: Option<&Url>,
|
in_reply_to: Option<&str>,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let capped: String = content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect();
|
let capped: String = content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect();
|
||||||
let (in_reply_to_id, in_reply_to_url) = match in_reply_to {
|
let (in_reply_to_id, in_reply_to_url) = match in_reply_to {
|
||||||
Some(url) => {
|
Some(url) => {
|
||||||
// If the parent is a local thought, extract its UUID for in_reply_to_id.
|
// If the parent is a local thought, extract its UUID for in_reply_to_id.
|
||||||
let local_uuid = url
|
let local_uuid = url::Url::parse(url).ok().and_then(|u| {
|
||||||
.path()
|
u.path()
|
||||||
.strip_prefix(THOUGHTS_PATH_PREFIX)
|
.strip_prefix(THOUGHTS_PATH_PREFIX)
|
||||||
.and_then(|s| s.split('/').next())
|
.and_then(|s| s.split('/').next())
|
||||||
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
.and_then(|s| uuid::Uuid::parse_str(s).ok())
|
||||||
(local_uuid, Some(url.as_str().to_string()))
|
});
|
||||||
|
(local_uuid, Some(url.to_string()))
|
||||||
}
|
}
|
||||||
None => (None, None),
|
None => (None, None),
|
||||||
};
|
};
|
||||||
@@ -244,7 +242,7 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
|||||||
.bind(uuid::Uuid::new_v4())
|
.bind(uuid::Uuid::new_v4())
|
||||||
.bind(author_id.as_uuid())
|
.bind(author_id.as_uuid())
|
||||||
.bind(&capped)
|
.bind(&capped)
|
||||||
.bind(ap_id.as_str())
|
.bind(ap_id)
|
||||||
.bind(sensitive)
|
.bind(sensitive)
|
||||||
.bind(content_warning)
|
.bind(content_warning)
|
||||||
.bind(published)
|
.bind(published)
|
||||||
@@ -257,12 +255,12 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
|||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn apply_note_update(&self, ap_id: &Url, new_content: &str) -> Result<(), DomainError> {
|
async fn apply_note_update(&self, ap_id: &str, new_content: &str) -> Result<(), DomainError> {
|
||||||
let capped: String = new_content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect();
|
let capped: String = new_content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE thoughts SET content=$2,updated_at=NOW() WHERE ap_id=$1 AND local=false",
|
"UPDATE thoughts SET content=$2,updated_at=NOW() WHERE ap_id=$1 AND local=false",
|
||||||
)
|
)
|
||||||
.bind(ap_id.as_str())
|
.bind(ap_id)
|
||||||
.bind(&capped)
|
.bind(&capped)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
@@ -270,20 +268,20 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
|||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn retract_note(&self, ap_id: &Url) -> Result<(), DomainError> {
|
async fn retract_note(&self, ap_id: &str) -> Result<(), DomainError> {
|
||||||
sqlx::query("DELETE FROM thoughts WHERE ap_id=$1 AND local=false")
|
sqlx::query("DELETE FROM thoughts WHERE ap_id=$1 AND local=false")
|
||||||
.bind(ap_id.as_str())
|
.bind(ap_id)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()
|
.into_domain()
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn retract_actor_notes(&self, actor_ap_url: &Url) -> Result<(), DomainError> {
|
async fn retract_actor_notes(&self, actor_ap_url: &str) -> Result<(), DomainError> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"DELETE FROM thoughts WHERE local=false AND user_id=(SELECT id FROM users WHERE ap_id=$1)",
|
"DELETE FROM thoughts WHERE local=false AND user_id=(SELECT id FROM users WHERE ap_id=$1)",
|
||||||
)
|
)
|
||||||
.bind(actor_ap_url.as_str())
|
.bind(actor_ap_url)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()
|
.into_domain()
|
||||||
@@ -297,6 +295,34 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
|||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
Ok(n as u64)
|
Ok(n as u64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_thought_ap_id(
|
||||||
|
&self,
|
||||||
|
thought_id: &ThoughtId,
|
||||||
|
) -> Result<Option<String>, DomainError> {
|
||||||
|
sqlx::query_scalar::<_, String>(
|
||||||
|
"SELECT ap_id FROM thoughts WHERE id = $1 AND ap_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
.bind(thought_id.as_uuid())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.into_domain()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_actor_ap_urls(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
) -> Result<Option<ActorApUrls>, DomainError> {
|
||||||
|
sqlx::query_as::<_, (String, String)>(
|
||||||
|
"SELECT ap_id, inbox_url FROM users \
|
||||||
|
WHERE id = $1 AND ap_id IS NOT NULL AND inbox_url IS NOT NULL",
|
||||||
|
)
|
||||||
|
.bind(user_id.as_uuid())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.into_domain()
|
||||||
|
.map(|opt| opt.map(|(ap_id, inbox_url)| ActorApUrls { ap_id, inbox_url }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -307,20 +333,20 @@ mod tests {
|
|||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) {
|
async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) {
|
||||||
let repo = PgActivityPubRepository::new(pool);
|
let repo = PgActivityPubRepository::new(pool);
|
||||||
let url = url::Url::parse("https://mastodon.social/users/alice").unwrap();
|
let url = "https://mastodon.social/users/alice";
|
||||||
let id1 = repo.intern_remote_actor(&url).await.unwrap();
|
let id1 = repo.intern_remote_actor(url).await.unwrap();
|
||||||
let id2 = repo.intern_remote_actor(&url).await.unwrap();
|
let id2 = repo.intern_remote_actor(url).await.unwrap();
|
||||||
assert_eq!(id1, id2);
|
assert_eq!(id1, id2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn accept_and_retract_note(pool: sqlx::PgPool) {
|
async fn accept_and_retract_note(pool: sqlx::PgPool) {
|
||||||
let repo = PgActivityPubRepository::new(pool);
|
let repo = PgActivityPubRepository::new(pool);
|
||||||
let actor_url = url::Url::parse("https://remote.example/users/bob").unwrap();
|
let actor_url = "https://remote.example/users/bob";
|
||||||
let ap_id = url::Url::parse("https://remote.example/notes/1").unwrap();
|
let ap_id = "https://remote.example/notes/1";
|
||||||
let author = repo.intern_remote_actor(&actor_url).await.unwrap();
|
let author = repo.intern_remote_actor(actor_url).await.unwrap();
|
||||||
repo.accept_note(
|
repo.accept_note(
|
||||||
&ap_id,
|
ap_id,
|
||||||
&author,
|
&author,
|
||||||
"hello from remote",
|
"hello from remote",
|
||||||
chrono::Utc::now(),
|
chrono::Utc::now(),
|
||||||
@@ -331,7 +357,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
repo.retract_note(&ap_id).await.unwrap();
|
repo.retract_note(ap_id).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::user::PgUserRepository;
|
use crate::user::PgUserRepository;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use domain::ports::UserRepository;
|
use domain::ports::UserWriter;
|
||||||
use domain::{models::user::User, value_objects::*};
|
use domain::{models::user::User, value_objects::*};
|
||||||
|
|
||||||
async fn seed_user(pool: &sqlx::PgPool) -> User {
|
async fn seed_user(pool: &sqlx::PgPool) -> User {
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ struct FeedRow {
|
|||||||
t_user_id: uuid::Uuid,
|
t_user_id: uuid::Uuid,
|
||||||
content: String,
|
content: String,
|
||||||
in_reply_to_id: Option<uuid::Uuid>,
|
in_reply_to_id: Option<uuid::Uuid>,
|
||||||
in_reply_to_url: Option<String>,
|
|
||||||
t_ap_id: Option<String>,
|
|
||||||
visibility: String,
|
visibility: String,
|
||||||
content_warning: Option<String>,
|
content_warning: Option<String>,
|
||||||
sensitive: bool,
|
sensitive: bool,
|
||||||
@@ -47,8 +45,6 @@ struct FeedRow {
|
|||||||
header_url: Option<String>,
|
header_url: Option<String>,
|
||||||
custom_css: Option<String>,
|
custom_css: Option<String>,
|
||||||
author_local: bool,
|
author_local: bool,
|
||||||
u_ap_id: Option<String>,
|
|
||||||
inbox_url: Option<String>,
|
|
||||||
author_created_at: DateTime<Utc>,
|
author_created_at: DateTime<Utc>,
|
||||||
author_updated_at: DateTime<Utc>,
|
author_updated_at: DateTime<Utc>,
|
||||||
like_count: i64,
|
like_count: i64,
|
||||||
@@ -83,7 +79,7 @@ fn feed_select(viewer: Option<uuid::Uuid>) -> String {
|
|||||||
"
|
"
|
||||||
SELECT
|
SELECT
|
||||||
t.id AS thought_id, t.user_id AS t_user_id, t.content,
|
t.id AS thought_id, t.user_id AS t_user_id, t.content,
|
||||||
t.in_reply_to_id, t.in_reply_to_url, t.ap_id AS t_ap_id,
|
t.in_reply_to_id,
|
||||||
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,
|
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,
|
||||||
t.created_at AS thought_created_at, t.updated_at,
|
t.created_at AS thought_created_at, t.updated_at,
|
||||||
u.id AS author_id,
|
u.id AS author_id,
|
||||||
@@ -98,7 +94,7 @@ fn feed_select(viewer: Option<uuid::Uuid>) -> String {
|
|||||||
u.bio,
|
u.bio,
|
||||||
COALESCE(ra.avatar_url, u.avatar_url) AS avatar_url,
|
COALESCE(ra.avatar_url, u.avatar_url) AS avatar_url,
|
||||||
u.header_url, u.custom_css,
|
u.header_url, u.custom_css,
|
||||||
u.local AS author_local, u.ap_id AS u_ap_id, u.inbox_url,
|
u.local AS author_local,
|
||||||
u.created_at AS author_created_at, u.updated_at AS author_updated_at,
|
u.created_at AS author_created_at, u.updated_at AS author_updated_at,
|
||||||
(SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,
|
(SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,
|
||||||
(SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,
|
(SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,
|
||||||
@@ -110,15 +106,13 @@ fn feed_select(viewer: Option<uuid::Uuid>) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn row_to_entry(r: FeedRow) -> FeedEntry {
|
fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, DomainError> {
|
||||||
let thought = Thought {
|
let thought = Thought {
|
||||||
id: ThoughtId::from_uuid(r.thought_id),
|
id: ThoughtId::from_uuid(r.thought_id),
|
||||||
user_id: UserId::from_uuid(r.t_user_id),
|
user_id: UserId::from_uuid(r.t_user_id),
|
||||||
content: Content::new_remote(r.content),
|
content: Content::new_remote(r.content),
|
||||||
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
|
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
|
||||||
in_reply_to_url: r.in_reply_to_url,
|
visibility: Visibility::from_db_str(&r.visibility)?,
|
||||||
ap_id: r.t_ap_id,
|
|
||||||
visibility: Visibility::from_db_str(&r.visibility),
|
|
||||||
content_warning: r.content_warning,
|
content_warning: r.content_warning,
|
||||||
sensitive: r.sensitive,
|
sensitive: r.sensitive,
|
||||||
local: r.t_local,
|
local: r.t_local,
|
||||||
@@ -136,20 +130,22 @@ fn row_to_entry(r: FeedRow) -> FeedEntry {
|
|||||||
header_url: r.header_url,
|
header_url: r.header_url,
|
||||||
custom_css: r.custom_css,
|
custom_css: r.custom_css,
|
||||||
local: r.author_local,
|
local: r.author_local,
|
||||||
ap_id: r.u_ap_id,
|
|
||||||
inbox_url: r.inbox_url,
|
|
||||||
created_at: r.author_created_at,
|
created_at: r.author_created_at,
|
||||||
updated_at: r.author_updated_at,
|
updated_at: r.author_updated_at,
|
||||||
};
|
};
|
||||||
FeedEntry {
|
Ok(FeedEntry {
|
||||||
thought,
|
thought,
|
||||||
author,
|
author,
|
||||||
|
stats: domain::models::feed::EngagementStats {
|
||||||
like_count: r.like_count,
|
like_count: r.like_count,
|
||||||
boost_count: r.boost_count,
|
boost_count: r.boost_count,
|
||||||
reply_count: r.reply_count,
|
reply_count: r.reply_count,
|
||||||
liked_by_viewer: r.liked_by_viewer,
|
},
|
||||||
boosted_by_viewer: r.boosted_by_viewer,
|
viewer: viewer.map(|_| domain::models::feed::ViewerContext {
|
||||||
}
|
liked: r.liked_by_viewer,
|
||||||
|
boosted: r.boosted_by_viewer,
|
||||||
|
}),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -184,7 +180,10 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
|
|
||||||
Ok(Paginated {
|
Ok(Paginated {
|
||||||
items: rows.into_iter().map(row_to_entry).collect(),
|
items: rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| row_to_entry(r, viewer))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
total,
|
total,
|
||||||
page: page.page,
|
page: page.page,
|
||||||
per_page: page.per_page,
|
per_page: page.per_page,
|
||||||
@@ -214,7 +213,10 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
|
|
||||||
Ok(Paginated {
|
Ok(Paginated {
|
||||||
items: rows.into_iter().map(row_to_entry).collect(),
|
items: rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| row_to_entry(r, viewer))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
total,
|
total,
|
||||||
page: page.page,
|
page: page.page,
|
||||||
per_page: page.per_page,
|
per_page: page.per_page,
|
||||||
@@ -247,7 +249,10 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
|
|
||||||
Ok(Paginated {
|
Ok(Paginated {
|
||||||
items: rows.into_iter().map(row_to_entry).collect(),
|
items: rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| row_to_entry(r, viewer))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
total,
|
total,
|
||||||
page: page.page,
|
page: page.page,
|
||||||
per_page: page.per_page,
|
per_page: page.per_page,
|
||||||
@@ -289,7 +294,10 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
|
|
||||||
Ok(Paginated {
|
Ok(Paginated {
|
||||||
items: rows.into_iter().map(row_to_entry).collect(),
|
items: rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| row_to_entry(r, viewer))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
total,
|
total,
|
||||||
page: page.page,
|
page: page.page,
|
||||||
per_page: page.per_page,
|
per_page: page.per_page,
|
||||||
@@ -329,7 +337,10 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
|
|
||||||
Ok(Paginated {
|
Ok(Paginated {
|
||||||
items: rows.into_iter().map(row_to_entry).collect(),
|
items: rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| row_to_entry(r, viewer))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
total,
|
total,
|
||||||
page: page.page,
|
page: page.page,
|
||||||
per_page: page.per_page,
|
per_page: page.per_page,
|
||||||
@@ -346,7 +357,7 @@ mod tests {
|
|||||||
thought::{Thought, Visibility},
|
thought::{Thought, Visibility},
|
||||||
user::User,
|
user::User,
|
||||||
},
|
},
|
||||||
ports::{ThoughtRepository, UserRepository},
|
ports::{ThoughtRepository, UserWriter},
|
||||||
value_objects::*,
|
value_objects::*,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -76,13 +76,18 @@ impl FollowRepository for PgFollowRepository {
|
|||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()
|
.into_domain()
|
||||||
.map(|o| o.map(|r| Follow {
|
.and_then(|o| {
|
||||||
|
o.map(|r| {
|
||||||
|
Ok(Follow {
|
||||||
follower_id: UserId::from_uuid(r.follower_id),
|
follower_id: UserId::from_uuid(r.follower_id),
|
||||||
following_id: UserId::from_uuid(r.following_id),
|
following_id: UserId::from_uuid(r.following_id),
|
||||||
state: FollowState::from_db_str(&r.state),
|
state: FollowState::from_db_str(&r.state)?,
|
||||||
ap_id: r.ap_id,
|
ap_id: r.ap_id,
|
||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
}))
|
})
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_state(
|
async fn update_state(
|
||||||
|
|||||||
@@ -2,33 +2,11 @@ use crate::db_error::IntoDbResult;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
fn notif_type_from_str(s: &str) -> domain::models::notification::NotificationType {
|
|
||||||
use domain::models::notification::NotificationType;
|
|
||||||
match s {
|
|
||||||
"like" => NotificationType::Like,
|
|
||||||
"boost" => NotificationType::Boost,
|
|
||||||
"follow" => NotificationType::Follow,
|
|
||||||
"mention" => NotificationType::Mention,
|
|
||||||
_ => NotificationType::Reply,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn notif_type_as_str(t: &domain::models::notification::NotificationType) -> &'static str {
|
|
||||||
use domain::models::notification::NotificationType;
|
|
||||||
match t {
|
|
||||||
NotificationType::Like => "like",
|
|
||||||
NotificationType::Boost => "boost",
|
|
||||||
NotificationType::Follow => "follow",
|
|
||||||
NotificationType::Mention => "mention",
|
|
||||||
NotificationType::Reply => "reply",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{
|
models::{
|
||||||
feed::{PageParams, Paginated},
|
feed::{PageParams, Paginated},
|
||||||
notification::Notification,
|
notification::{Notification, NotificationKind},
|
||||||
},
|
},
|
||||||
ports::NotificationRepository,
|
ports::NotificationRepository,
|
||||||
value_objects::{NotificationId, ThoughtId, UserId},
|
value_objects::{NotificationId, ThoughtId, UserId},
|
||||||
@@ -44,17 +22,83 @@ impl PgNotificationRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct NotificationRow {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
notification_type: String,
|
||||||
|
from_user_id: Option<uuid::Uuid>,
|
||||||
|
thought_id: Option<uuid::Uuid>,
|
||||||
|
read: bool,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_notification(r: NotificationRow) -> Result<Notification, DomainError> {
|
||||||
|
let from_user_id = r
|
||||||
|
.from_user_id
|
||||||
|
.map(UserId::from_uuid)
|
||||||
|
.ok_or_else(|| DomainError::Internal("notification missing from_user_id".into()))?;
|
||||||
|
|
||||||
|
let kind = match r.notification_type.as_str() {
|
||||||
|
"follow" => NotificationKind::Follow { from_user_id },
|
||||||
|
other => {
|
||||||
|
let thought_id = r.thought_id.map(ThoughtId::from_uuid).ok_or_else(|| {
|
||||||
|
DomainError::Internal(format!("notification type '{other}' missing thought_id"))
|
||||||
|
})?;
|
||||||
|
match other {
|
||||||
|
"like" => NotificationKind::Like {
|
||||||
|
thought_id,
|
||||||
|
from_user_id,
|
||||||
|
},
|
||||||
|
"boost" => NotificationKind::Boost {
|
||||||
|
thought_id,
|
||||||
|
from_user_id,
|
||||||
|
},
|
||||||
|
"reply" => NotificationKind::Reply {
|
||||||
|
thought_id,
|
||||||
|
from_user_id,
|
||||||
|
},
|
||||||
|
"mention" => NotificationKind::Mention {
|
||||||
|
thought_id,
|
||||||
|
from_user_id,
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
return Err(DomainError::Internal(format!(
|
||||||
|
"unknown notification type: {other}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Notification {
|
||||||
|
id: NotificationId::from_uuid(r.id),
|
||||||
|
user_id: UserId::from_uuid(r.user_id),
|
||||||
|
kind,
|
||||||
|
read: r.read,
|
||||||
|
created_at: r.created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl NotificationRepository for PgNotificationRepository {
|
impl NotificationRepository for PgNotificationRepository {
|
||||||
async fn save(&self, n: &Notification) -> Result<(), DomainError> {
|
async fn save(&self, n: &Notification) -> Result<(), DomainError> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO notifications(id,user_id,type,from_user_id,thought_id,read,created_at) VALUES($1,$2,$3,$4,$5,$6,$7)"
|
"INSERT INTO notifications(id,user_id,notification_type,from_user_id,thought_id,read,created_at)
|
||||||
|
VALUES($1,$2,$3,$4,$5,$6,$7)
|
||||||
|
ON CONFLICT(id) DO NOTHING"
|
||||||
)
|
)
|
||||||
.bind(n.id.as_uuid()).bind(n.user_id.as_uuid()).bind(notif_type_as_str(&n.notification_type))
|
.bind(n.id.as_uuid())
|
||||||
.bind(n.from_user_id.as_ref().map(|u| u.as_uuid()))
|
.bind(n.user_id.as_uuid())
|
||||||
.bind(n.thought_id.as_ref().map(|t| t.as_uuid()))
|
.bind(n.kind.kind_str())
|
||||||
.bind(n.read).bind(n.created_at)
|
.bind(n.kind.from_user_id().as_uuid())
|
||||||
.execute(&self.pool).await.into_domain().map(|_| ())
|
.bind(n.kind.thought_id().map(|t| t.as_uuid()))
|
||||||
|
.bind(n.read)
|
||||||
|
.bind(n.created_at)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.into_domain()
|
||||||
|
.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_for_user(
|
async fn list_for_user(
|
||||||
@@ -67,32 +111,14 @@ impl NotificationRepository for PgNotificationRepository {
|
|||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
#[derive(sqlx::FromRow)]
|
let rows = sqlx::query_as::<_, NotificationRow>(
|
||||||
struct Row {
|
"SELECT id,user_id,notification_type,from_user_id,thought_id,read,created_at FROM notifications WHERE user_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3"
|
||||||
id: uuid::Uuid,
|
|
||||||
user_id: uuid::Uuid,
|
|
||||||
r#type: String,
|
|
||||||
from_user_id: Option<uuid::Uuid>,
|
|
||||||
thought_id: Option<uuid::Uuid>,
|
|
||||||
read: bool,
|
|
||||||
created_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
let rows = sqlx::query_as::<_, Row>(
|
|
||||||
"SELECT id,user_id,type,from_user_id,thought_id,read,created_at FROM notifications WHERE user_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3"
|
|
||||||
).bind(user_id.as_uuid()).bind(page.limit()).bind(page.offset())
|
).bind(user_id.as_uuid()).bind(page.limit()).bind(page.offset())
|
||||||
.fetch_all(&self.pool).await.into_domain()?;
|
.fetch_all(&self.pool).await.into_domain()?;
|
||||||
let items = rows
|
let items = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| Notification {
|
.map(row_to_notification)
|
||||||
id: NotificationId::from_uuid(r.id),
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
user_id: UserId::from_uuid(r.user_id),
|
|
||||||
notification_type: notif_type_from_str(&r.r#type),
|
|
||||||
from_user_id: r.from_user_id.map(UserId::from_uuid),
|
|
||||||
thought_id: r.thought_id.map(ThoughtId::from_uuid),
|
|
||||||
read: r.read,
|
|
||||||
created_at: r.created_at,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
Ok(Paginated {
|
Ok(Paginated {
|
||||||
items,
|
items,
|
||||||
total,
|
total,
|
||||||
@@ -135,37 +161,25 @@ impl NotificationRepository for PgNotificationRepository {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::user::PgUserRepository;
|
use crate::test_helpers;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use domain::ports::UserRepository;
|
|
||||||
use domain::{
|
use domain::{
|
||||||
models::{notification::NotificationType, user::User},
|
models::{notification::NotificationKind, user::User},
|
||||||
value_objects::*,
|
value_objects::*,
|
||||||
};
|
};
|
||||||
|
|
||||||
async fn seed_user(pool: &sqlx::PgPool) -> User {
|
|
||||||
let repo = PgUserRepository::new(pool.clone());
|
|
||||||
let u = User::new_local(
|
|
||||||
UserId::new(),
|
|
||||||
Username::new("alice").unwrap(),
|
|
||||||
Email::new("alice@ex.com").unwrap(),
|
|
||||||
PasswordHash("h".into()),
|
|
||||||
);
|
|
||||||
repo.save(&u).await.unwrap();
|
|
||||||
u
|
|
||||||
}
|
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn save_and_list(pool: sqlx::PgPool) {
|
async fn save_and_list(pool: sqlx::PgPool) {
|
||||||
let user = seed_user(&pool).await;
|
let user = test_helpers::seed_user(&pool, "alice", "alice@ex.com").await;
|
||||||
|
let from_user = test_helpers::seed_user(&pool, "bob", "bob@ex.com").await;
|
||||||
let repo = PgNotificationRepository::new(pool);
|
let repo = PgNotificationRepository::new(pool);
|
||||||
use domain::models::feed::PageParams;
|
use domain::models::feed::PageParams;
|
||||||
let n = Notification {
|
let n = Notification {
|
||||||
id: NotificationId::new(),
|
id: NotificationId::new(),
|
||||||
user_id: user.id.clone(),
|
user_id: user.id.clone(),
|
||||||
notification_type: NotificationType::Like,
|
kind: NotificationKind::Follow {
|
||||||
from_user_id: None,
|
from_user_id: from_user.id.clone(),
|
||||||
thought_id: None,
|
},
|
||||||
read: false,
|
read: false,
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
};
|
};
|
||||||
@@ -186,15 +200,16 @@ mod tests {
|
|||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn mark_all_read(pool: sqlx::PgPool) {
|
async fn mark_all_read(pool: sqlx::PgPool) {
|
||||||
let user = seed_user(&pool).await;
|
let user = test_helpers::seed_user(&pool, "alice", "alice@ex.com").await;
|
||||||
|
let from_user = test_helpers::seed_user(&pool, "bob", "bob@ex.com").await;
|
||||||
let repo = PgNotificationRepository::new(pool);
|
let repo = PgNotificationRepository::new(pool);
|
||||||
use domain::models::feed::PageParams;
|
use domain::models::feed::PageParams;
|
||||||
let n = Notification {
|
let n = Notification {
|
||||||
id: NotificationId::new(),
|
id: NotificationId::new(),
|
||||||
user_id: user.id.clone(),
|
user_id: user.id.clone(),
|
||||||
notification_type: NotificationType::Follow,
|
kind: NotificationKind::Follow {
|
||||||
from_user_id: None,
|
from_user_id: from_user.id.clone(),
|
||||||
thought_id: None,
|
},
|
||||||
read: false,
|
read: false,
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,14 +19,12 @@ impl PgRemoteActorRepository {
|
|||||||
impl RemoteActorRepository for PgRemoteActorRepository {
|
impl RemoteActorRepository for PgRemoteActorRepository {
|
||||||
async fn upsert(&self, a: &RemoteActor) -> Result<(), DomainError> {
|
async fn upsert(&self, a: &RemoteActor) -> Result<(), DomainError> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO remote_actors(url,handle,display_name,inbox_url,shared_inbox_url,public_key,avatar_url,last_fetched_at)
|
"INSERT INTO remote_actors(url,handle,display_name,avatar_url,last_fetched_at)
|
||||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8)
|
VALUES($1,$2,$3,$4,$5)
|
||||||
ON CONFLICT(url) DO UPDATE SET handle=EXCLUDED.handle,display_name=EXCLUDED.display_name,
|
ON CONFLICT(url) DO UPDATE SET handle=EXCLUDED.handle,display_name=EXCLUDED.display_name,
|
||||||
inbox_url=EXCLUDED.inbox_url,shared_inbox_url=EXCLUDED.shared_inbox_url,
|
avatar_url=EXCLUDED.avatar_url,last_fetched_at=EXCLUDED.last_fetched_at"
|
||||||
public_key=EXCLUDED.public_key,avatar_url=EXCLUDED.avatar_url,last_fetched_at=EXCLUDED.last_fetched_at"
|
|
||||||
)
|
)
|
||||||
.bind(&a.url).bind(&a.handle).bind(&a.display_name).bind(&a.inbox_url)
|
.bind(&a.url).bind(&a.handle).bind(&a.display_name).bind(&a.avatar_url).bind(a.last_fetched_at)
|
||||||
.bind(&a.shared_inbox_url).bind(&a.public_key).bind(&a.avatar_url).bind(a.last_fetched_at)
|
|
||||||
.execute(&self.pool).await.into_domain().map(|_| ())
|
.execute(&self.pool).await.into_domain().map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,16 +34,26 @@ impl RemoteActorRepository for PgRemoteActorRepository {
|
|||||||
url: String,
|
url: String,
|
||||||
handle: String,
|
handle: String,
|
||||||
display_name: Option<String>,
|
display_name: Option<String>,
|
||||||
inbox_url: String,
|
|
||||||
shared_inbox_url: Option<String>,
|
|
||||||
public_key: String,
|
|
||||||
avatar_url: Option<String>,
|
avatar_url: Option<String>,
|
||||||
last_fetched_at: DateTime<Utc>,
|
last_fetched_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
sqlx::query_as::<_, Row>(
|
sqlx::query_as::<_, Row>(
|
||||||
"SELECT url,handle,display_name,inbox_url,shared_inbox_url,public_key,avatar_url,last_fetched_at FROM remote_actors WHERE url=$1"
|
"SELECT url,handle,display_name,avatar_url,last_fetched_at FROM remote_actors WHERE url=$1"
|
||||||
).bind(url).fetch_optional(&self.pool).await
|
).bind(url).fetch_optional(&self.pool).await
|
||||||
.into_domain()
|
.into_domain()
|
||||||
.map(|o| o.map(|r| RemoteActor { url: r.url, handle: r.handle, display_name: r.display_name, inbox_url: r.inbox_url, shared_inbox_url: r.shared_inbox_url, public_key: r.public_key, avatar_url: r.avatar_url, last_fetched_at: r.last_fetched_at, bio: None, banner_url: None, also_known_as: None, outbox_url: None, followers_url: None, following_url: None, attachment: vec![] }))
|
.map(|o| o.map(|r| RemoteActor {
|
||||||
|
url: r.url,
|
||||||
|
handle: r.handle,
|
||||||
|
display_name: r.display_name,
|
||||||
|
avatar_url: r.avatar_url,
|
||||||
|
last_fetched_at: r.last_fetched_at,
|
||||||
|
bio: None,
|
||||||
|
banner_url: None,
|
||||||
|
also_known_as: None,
|
||||||
|
outbox_url: None,
|
||||||
|
followers_url: None,
|
||||||
|
following_url: None,
|
||||||
|
attachment: vec![],
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,10 @@ impl TagRepository for PgTagRepository {
|
|||||||
.fetch_all(&self.pool).await.into_domain()?;
|
.fetch_all(&self.pool).await.into_domain()?;
|
||||||
|
|
||||||
Ok(Paginated {
|
Ok(Paginated {
|
||||||
items: rows.into_iter().map(Thought::from).collect(),
|
items: rows
|
||||||
|
.into_iter()
|
||||||
|
.map(Thought::try_from)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
total,
|
total,
|
||||||
page: page.page,
|
page: page.page,
|
||||||
per_page: page.per_page,
|
per_page: page.per_page,
|
||||||
@@ -132,7 +135,7 @@ impl TagRepository for PgTagRepository {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
||||||
use domain::ports::{ThoughtRepository, UserRepository};
|
use domain::ports::{ThoughtRepository, UserWriter};
|
||||||
use domain::{
|
use domain::{
|
||||||
models::{
|
models::{
|
||||||
thought::{Thought, Visibility},
|
thought::{Thought, Visibility},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use domain::{
|
|||||||
thought::{Thought, Visibility},
|
thought::{Thought, Visibility},
|
||||||
user::User,
|
user::User,
|
||||||
},
|
},
|
||||||
ports::{ThoughtRepository, UserRepository},
|
ports::{ThoughtRepository, UserWriter},
|
||||||
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
|
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ pub(crate) struct ThoughtRow {
|
|||||||
pub user_id: uuid::Uuid,
|
pub user_id: uuid::Uuid,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub in_reply_to_id: Option<uuid::Uuid>,
|
pub in_reply_to_id: Option<uuid::Uuid>,
|
||||||
pub in_reply_to_url: Option<String>,
|
|
||||||
pub ap_id: Option<String>,
|
|
||||||
pub visibility: String,
|
pub visibility: String,
|
||||||
pub content_warning: Option<String>,
|
pub content_warning: Option<String>,
|
||||||
pub sensitive: bool,
|
pub sensitive: bool,
|
||||||
@@ -38,42 +36,39 @@ pub(crate) struct ThoughtRow {
|
|||||||
pub updated_at: Option<DateTime<Utc>>,
|
pub updated_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<ThoughtRow> for Thought {
|
impl TryFrom<ThoughtRow> for Thought {
|
||||||
fn from(r: ThoughtRow) -> Self {
|
type Error = DomainError;
|
||||||
Thought {
|
fn try_from(r: ThoughtRow) -> Result<Self, DomainError> {
|
||||||
|
Ok(Thought {
|
||||||
id: ThoughtId::from_uuid(r.id),
|
id: ThoughtId::from_uuid(r.id),
|
||||||
user_id: UserId::from_uuid(r.user_id),
|
user_id: UserId::from_uuid(r.user_id),
|
||||||
content: Content::new_remote(r.content),
|
content: Content::new_remote(r.content),
|
||||||
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
|
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
|
||||||
in_reply_to_url: r.in_reply_to_url,
|
visibility: Visibility::from_db_str(&r.visibility)?,
|
||||||
ap_id: r.ap_id,
|
|
||||||
visibility: Visibility::from_db_str(&r.visibility),
|
|
||||||
content_warning: r.content_warning,
|
content_warning: r.content_warning,
|
||||||
sensitive: r.sensitive,
|
sensitive: r.sensitive,
|
||||||
local: r.local,
|
local: r.local,
|
||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
updated_at: r.updated_at,
|
updated_at: r.updated_at,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const THOUGHT_SELECT: &str =
|
const THOUGHT_SELECT: &str =
|
||||||
"SELECT id,user_id,content,in_reply_to_id,in_reply_to_url,ap_id,visibility,content_warning,sensitive,local,created_at,updated_at FROM thoughts";
|
"SELECT id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at,updated_at FROM thoughts";
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ThoughtRepository for PgThoughtRepository {
|
impl ThoughtRepository for PgThoughtRepository {
|
||||||
async fn save(&self, t: &Thought) -> Result<(), DomainError> {
|
async fn save(&self, t: &Thought) -> Result<(), DomainError> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO thoughts(id,user_id,content,in_reply_to_id,in_reply_to_url,ap_id,visibility,content_warning,sensitive,local,created_at)
|
"INSERT INTO thoughts(id,user_id,content,in_reply_to_id,visibility,content_warning,sensitive,local,created_at)
|
||||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||||
ON CONFLICT(id) DO UPDATE SET content=EXCLUDED.content,updated_at=NOW()"
|
ON CONFLICT(id) DO UPDATE SET content=EXCLUDED.content,updated_at=NOW()"
|
||||||
)
|
)
|
||||||
.bind(t.id.as_uuid())
|
.bind(t.id.as_uuid())
|
||||||
.bind(t.user_id.as_uuid())
|
.bind(t.user_id.as_uuid())
|
||||||
.bind(t.content.as_str())
|
.bind(t.content.as_str())
|
||||||
.bind(t.in_reply_to_id.as_ref().map(|x| x.as_uuid()))
|
.bind(t.in_reply_to_id.as_ref().map(|x| x.as_uuid()))
|
||||||
.bind(&t.in_reply_to_url)
|
|
||||||
.bind(&t.ap_id)
|
|
||||||
.bind(t.visibility.as_str())
|
.bind(t.visibility.as_str())
|
||||||
.bind(&t.content_warning)
|
.bind(&t.content_warning)
|
||||||
.bind(t.sensitive)
|
.bind(t.sensitive)
|
||||||
@@ -91,7 +86,7 @@ impl ThoughtRepository for PgThoughtRepository {
|
|||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()
|
.into_domain()
|
||||||
.map(|o| o.map(Thought::from))
|
.and_then(|o| o.map(Thought::try_from).transpose())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete(&self, id: &ThoughtId, user_id: &UserId) -> Result<(), DomainError> {
|
async fn delete(&self, id: &ThoughtId, user_id: &UserId) -> Result<(), DomainError> {
|
||||||
@@ -121,11 +116,11 @@ impl ThoughtRepository for PgThoughtRepository {
|
|||||||
// Recursive CTE: fetches the root thought and all nested replies at any depth.
|
// Recursive CTE: fetches the root thought and all nested replies at any depth.
|
||||||
sqlx::query_as::<_, ThoughtRow>(
|
sqlx::query_as::<_, ThoughtRow>(
|
||||||
"WITH RECURSIVE thread AS (
|
"WITH RECURSIVE thread AS (
|
||||||
SELECT id,user_id,content,in_reply_to_id,in_reply_to_url,ap_id,
|
SELECT id,user_id,content,in_reply_to_id,
|
||||||
visibility,content_warning,sensitive,local,created_at,updated_at
|
visibility,content_warning,sensitive,local,created_at,updated_at
|
||||||
FROM thoughts WHERE id = $1
|
FROM thoughts WHERE id = $1
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT t.id,t.user_id,t.content,t.in_reply_to_id,t.in_reply_to_url,t.ap_id,
|
SELECT t.id,t.user_id,t.content,t.in_reply_to_id,
|
||||||
t.visibility,t.content_warning,t.sensitive,t.local,t.created_at,t.updated_at
|
t.visibility,t.content_warning,t.sensitive,t.local,t.created_at,t.updated_at
|
||||||
FROM thoughts t JOIN thread ON t.in_reply_to_id = thread.id
|
FROM thoughts t JOIN thread ON t.in_reply_to_id = thread.id
|
||||||
)
|
)
|
||||||
@@ -135,7 +130,7 @@ impl ThoughtRepository for PgThoughtRepository {
|
|||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.into_domain()
|
.into_domain()
|
||||||
.map(|rows| rows.into_iter().map(Thought::from).collect())
|
.and_then(|rows| rows.into_iter().map(Thought::try_from).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_by_user(
|
async fn list_by_user(
|
||||||
@@ -161,7 +156,10 @@ impl ThoughtRepository for PgThoughtRepository {
|
|||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
|
|
||||||
Ok(Paginated {
|
Ok(Paginated {
|
||||||
items: rows.into_iter().map(Thought::from).collect(),
|
items: rows
|
||||||
|
.into_iter()
|
||||||
|
.map(Thought::try_from)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
total,
|
total,
|
||||||
page: page.page,
|
page: page.page,
|
||||||
per_page: page.per_page,
|
per_page: page.per_page,
|
||||||
|
|||||||
@@ -58,15 +58,13 @@ impl TopFriendRepository for PgTopFriendRepository {
|
|||||||
header_url: Option<String>,
|
header_url: Option<String>,
|
||||||
custom_css: Option<String>,
|
custom_css: Option<String>,
|
||||||
local: bool,
|
local: bool,
|
||||||
ap_id: Option<String>,
|
|
||||||
inbox_url: Option<String>,
|
|
||||||
created_at: chrono::DateTime<chrono::Utc>,
|
created_at: chrono::DateTime<chrono::Utc>,
|
||||||
updated_at: chrono::DateTime<chrono::Utc>,
|
updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
let rows = sqlx::query_as::<_, Row>(
|
let rows = sqlx::query_as::<_, Row>(
|
||||||
"SELECT tf.user_id AS tf_user_id, tf.friend_id, tf.position,
|
"SELECT tf.user_id AS tf_user_id, tf.friend_id, tf.position,
|
||||||
u.id, u.username, u.email, u.password_hash, u.display_name, u.bio,
|
u.id, u.username, u.email, u.password_hash, u.display_name, u.bio,
|
||||||
u.avatar_url, u.header_url, u.custom_css, u.local, u.ap_id, u.inbox_url,
|
u.avatar_url, u.header_url, u.custom_css, u.local,
|
||||||
u.created_at, u.updated_at
|
u.created_at, u.updated_at
|
||||||
FROM top_friends tf JOIN users u ON u.id=tf.friend_id
|
FROM top_friends tf JOIN users u ON u.id=tf.friend_id
|
||||||
WHERE tf.user_id=$1 ORDER BY tf.position",
|
WHERE tf.user_id=$1 ORDER BY tf.position",
|
||||||
@@ -96,8 +94,6 @@ impl TopFriendRepository for PgTopFriendRepository {
|
|||||||
header_url: r.header_url,
|
header_url: r.header_url,
|
||||||
custom_css: r.custom_css,
|
custom_css: r.custom_css,
|
||||||
local: r.local,
|
local: r.local,
|
||||||
ap_id: r.ap_id,
|
|
||||||
inbox_url: r.inbox_url,
|
|
||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
updated_at: r.updated_at,
|
updated_at: r.updated_at,
|
||||||
};
|
};
|
||||||
@@ -111,7 +107,7 @@ impl TopFriendRepository for PgTopFriendRepository {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::user::PgUserRepository;
|
use crate::user::PgUserRepository;
|
||||||
use domain::ports::UserRepository;
|
use domain::ports::UserWriter;
|
||||||
use domain::{models::user::User, value_objects::*};
|
use domain::{models::user::User, value_objects::*};
|
||||||
|
|
||||||
async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User {
|
async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{feed::UserSummary, user::User},
|
models::{feed::UserSummary, user::User},
|
||||||
ports::UserRepository,
|
ports::{UserReader, UserWriter},
|
||||||
value_objects::{Email, PasswordHash, UserId, Username},
|
value_objects::{Email, PasswordHash, UserId, Username},
|
||||||
};
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
@@ -30,8 +30,6 @@ pub struct UserRow {
|
|||||||
pub header_url: Option<String>,
|
pub header_url: Option<String>,
|
||||||
pub custom_css: Option<String>,
|
pub custom_css: Option<String>,
|
||||||
pub local: bool,
|
pub local: bool,
|
||||||
pub ap_id: Option<String>,
|
|
||||||
pub inbox_url: Option<String>,
|
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
@@ -49,18 +47,18 @@ impl From<UserRow> for User {
|
|||||||
header_url: r.header_url,
|
header_url: r.header_url,
|
||||||
custom_css: r.custom_css,
|
custom_css: r.custom_css,
|
||||||
local: r.local,
|
local: r.local,
|
||||||
ap_id: r.ap_id,
|
|
||||||
inbox_url: r.inbox_url,
|
|
||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
updated_at: r.updated_at,
|
updated_at: r.updated_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const USER_SELECT: &str = "SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,ap_id,inbox_url,created_at,updated_at FROM users";
|
pub const USER_SELECT: &str =
|
||||||
|
"SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,\
|
||||||
|
custom_css,local,created_at,updated_at FROM users";
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl UserRepository for PgUserRepository {
|
impl UserReader for PgUserRepository {
|
||||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||||
sqlx::query_as::<_, UserRow>(&format!("{USER_SELECT} WHERE id=$1"))
|
sqlx::query_as::<_, UserRow>(&format!("{USER_SELECT} WHERE id=$1"))
|
||||||
.bind(id.as_uuid())
|
.bind(id.as_uuid())
|
||||||
@@ -88,62 +86,6 @@ impl UserRepository for PgUserRepository {
|
|||||||
.map(|o| o.map(User::from))
|
.map(|o| o.map(User::from))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO users (id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,ap_id,inbox_url,created_at,updated_at)
|
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
|
||||||
username=EXCLUDED.username, email=EXCLUDED.email,
|
|
||||||
password_hash=EXCLUDED.password_hash, display_name=EXCLUDED.display_name,
|
|
||||||
bio=EXCLUDED.bio, avatar_url=EXCLUDED.avatar_url,
|
|
||||||
header_url=EXCLUDED.header_url, custom_css=EXCLUDED.custom_css,
|
|
||||||
local=EXCLUDED.local, ap_id=EXCLUDED.ap_id, inbox_url=EXCLUDED.inbox_url,
|
|
||||||
updated_at=NOW()"
|
|
||||||
)
|
|
||||||
.bind(user.id.as_uuid())
|
|
||||||
.bind(user.username.as_str())
|
|
||||||
.bind(user.email.as_str())
|
|
||||||
.bind(&user.password_hash.0)
|
|
||||||
.bind(&user.display_name)
|
|
||||||
.bind(&user.bio)
|
|
||||||
.bind(&user.avatar_url)
|
|
||||||
.bind(&user.header_url)
|
|
||||||
.bind(&user.custom_css)
|
|
||||||
.bind(user.local)
|
|
||||||
.bind(&user.ap_id)
|
|
||||||
.bind(&user.inbox_url)
|
|
||||||
.bind(user.created_at)
|
|
||||||
.bind(user.updated_at)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.into_domain()
|
|
||||||
.map(|_| ())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update_profile(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
display_name: Option<String>,
|
|
||||||
bio: Option<String>,
|
|
||||||
avatar_url: Option<String>,
|
|
||||||
header_url: Option<String>,
|
|
||||||
custom_css: Option<String>,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE users SET display_name=$2,bio=$3,avatar_url=$4,header_url=$5,custom_css=$6,updated_at=NOW() WHERE id=$1"
|
|
||||||
)
|
|
||||||
.bind(user_id.as_uuid())
|
|
||||||
.bind(display_name)
|
|
||||||
.bind(bio)
|
|
||||||
.bind(avatar_url)
|
|
||||||
.bind(header_url)
|
|
||||||
.bind(custom_css)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.into_domain()
|
|
||||||
.map(|_| ())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
|
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct Row {
|
struct Row {
|
||||||
@@ -196,6 +138,63 @@ impl UserRepository for PgUserRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl UserWriter for PgUserRepository {
|
||||||
|
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO users (id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,created_at,updated_at)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
username=EXCLUDED.username, email=EXCLUDED.email,
|
||||||
|
password_hash=EXCLUDED.password_hash, display_name=EXCLUDED.display_name,
|
||||||
|
bio=EXCLUDED.bio, avatar_url=EXCLUDED.avatar_url,
|
||||||
|
header_url=EXCLUDED.header_url, custom_css=EXCLUDED.custom_css,
|
||||||
|
local=EXCLUDED.local,
|
||||||
|
updated_at=NOW()"
|
||||||
|
)
|
||||||
|
.bind(user.id.as_uuid())
|
||||||
|
.bind(user.username.as_str())
|
||||||
|
.bind(user.email.as_str())
|
||||||
|
.bind(&user.password_hash.0)
|
||||||
|
.bind(&user.display_name)
|
||||||
|
.bind(&user.bio)
|
||||||
|
.bind(&user.avatar_url)
|
||||||
|
.bind(&user.header_url)
|
||||||
|
.bind(&user.custom_css)
|
||||||
|
.bind(user.local)
|
||||||
|
.bind(user.created_at)
|
||||||
|
.bind(user.updated_at)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.into_domain()
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_profile(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
display_name: Option<String>,
|
||||||
|
bio: Option<String>,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
header_url: Option<String>,
|
||||||
|
custom_css: Option<String>,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE users SET display_name=$2,bio=$3,avatar_url=$4,header_url=$5,custom_css=$6,updated_at=NOW() WHERE id=$1"
|
||||||
|
)
|
||||||
|
.bind(user_id.as_uuid())
|
||||||
|
.bind(display_name)
|
||||||
|
.bind(bio)
|
||||||
|
.bind(avatar_url)
|
||||||
|
.bind(header_url)
|
||||||
|
.bind(custom_css)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.into_domain()
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -1,28 +1,26 @@
|
|||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::thought::{Thought, Visibility},
|
models::thought::Visibility,
|
||||||
ports::{ActivityPubRepository, OutboundFederationPort, ThoughtRepository, UserRepository},
|
ports::{ActivityPubRepository, OutboundFederationPort, ThoughtRepository, UserReader},
|
||||||
value_objects::ThoughtId,
|
value_objects::ThoughtId,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub struct FederationEventService {
|
pub struct FederationEventService {
|
||||||
pub thoughts: Arc<dyn ThoughtRepository>,
|
pub thoughts: Arc<dyn ThoughtRepository>,
|
||||||
pub users: Arc<dyn UserRepository>,
|
pub users: Arc<dyn UserReader>,
|
||||||
pub ap: Arc<dyn OutboundFederationPort>,
|
pub ap: Arc<dyn OutboundFederationPort>,
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
pub federation_action: Arc<dyn domain::ports::FederationActionPort>,
|
|
||||||
pub ap_repo: Arc<dyn ActivityPubRepository>,
|
pub ap_repo: Arc<dyn ActivityPubRepository>,
|
||||||
pub remote_actor_connections: Arc<dyn domain::ports::RemoteActorConnectionRepository>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FederationEventService {
|
impl FederationEventService {
|
||||||
fn object_ap_id(&self, thought: &Thought, thought_id: &ThoughtId) -> String {
|
async fn object_ap_id(&self, thought_id: &ThoughtId) -> Result<String, DomainError> {
|
||||||
thought
|
if let Some(ap_id) = self.ap_repo.get_thought_ap_id(thought_id).await? {
|
||||||
.ap_id
|
return Ok(ap_id);
|
||||||
.clone()
|
}
|
||||||
.unwrap_or_else(|| format!("{}/thoughts/{}", self.base_url, thought_id))
|
Ok(format!("{}/thoughts/{}", self.base_url, thought_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn process(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
pub async fn process(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||||
@@ -48,31 +46,24 @@ impl FederationEventService {
|
|||||||
Some(u) => u,
|
Some(u) => u,
|
||||||
None => return Ok(()),
|
None => return Ok(()),
|
||||||
};
|
};
|
||||||
// For replies to remote posts: in_reply_to_url is None but in_reply_to_id
|
// Resolve in_reply_to_url for the parent thought via AP repo.
|
||||||
// points to the locally-stored remote thought. Resolve its ap_id so the
|
let in_reply_to_url = if let Some(ref reply_id) = thought.in_reply_to_id {
|
||||||
// outbound Note includes inReplyTo and Mastodon threads it correctly.
|
let ap_id = self
|
||||||
let thought = if thought.in_reply_to_url.is_none() {
|
.ap_repo
|
||||||
if let Some(ref reply_id) = thought.in_reply_to_id {
|
.get_thought_ap_id(reply_id)
|
||||||
match self.thoughts.find_by_id(reply_id).await? {
|
.await?
|
||||||
Some(parent) => {
|
.unwrap_or_else(|| format!("{}/thoughts/{}", self.base_url, reply_id));
|
||||||
let parent_ap_url = parent.ap_id.unwrap_or_else(|| {
|
Some(ap_id)
|
||||||
format!("{}/thoughts/{}", self.base_url, reply_id)
|
|
||||||
});
|
|
||||||
domain::models::thought::Thought {
|
|
||||||
in_reply_to_url: Some(parent_ap_url),
|
|
||||||
..thought
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => thought,
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
thought
|
None
|
||||||
}
|
|
||||||
} else {
|
|
||||||
thought
|
|
||||||
};
|
};
|
||||||
self.ap
|
self.ap
|
||||||
.broadcast_create(user_id, &thought, user.username.as_str())
|
.broadcast_create(
|
||||||
|
user_id,
|
||||||
|
&thought,
|
||||||
|
user.username.as_str(),
|
||||||
|
in_reply_to_url.as_deref(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,8 +97,21 @@ impl FederationEventService {
|
|||||||
Some(u) => u,
|
Some(u) => u,
|
||||||
None => return Ok(()),
|
None => return Ok(()),
|
||||||
};
|
};
|
||||||
|
let in_reply_to_url = if let Some(ref reply_id) = thought.in_reply_to_id {
|
||||||
|
self.ap_repo
|
||||||
|
.get_thought_ap_id(reply_id)
|
||||||
|
.await?
|
||||||
|
.or_else(|| Some(format!("{}/thoughts/{}", self.base_url, reply_id)))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
self.ap
|
self.ap
|
||||||
.broadcast_update(user_id, &thought, user.username.as_str())
|
.broadcast_update(
|
||||||
|
user_id,
|
||||||
|
&thought,
|
||||||
|
user.username.as_str(),
|
||||||
|
in_reply_to_url.as_deref(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,11 +126,10 @@ impl FederationEventService {
|
|||||||
_ => return Ok(()),
|
_ => return Ok(()),
|
||||||
};
|
};
|
||||||
let _ = booster;
|
let _ = booster;
|
||||||
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
if self.thoughts.find_by_id(thought_id).await?.is_none() {
|
||||||
Some(t) => t,
|
return Ok(());
|
||||||
None => return Ok(()),
|
}
|
||||||
};
|
let object_ap_id = self.object_ap_id(thought_id).await?;
|
||||||
let object_ap_id = self.object_ap_id(&thought, thought_id);
|
|
||||||
self.ap.broadcast_announce(user_id, &object_ap_id).await
|
self.ap.broadcast_announce(user_id, &object_ap_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,122 +137,15 @@ impl FederationEventService {
|
|||||||
user_id,
|
user_id,
|
||||||
thought_id,
|
thought_id,
|
||||||
} => {
|
} => {
|
||||||
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
if self.thoughts.find_by_id(thought_id).await?.is_none() {
|
||||||
Some(t) => t,
|
return Ok(());
|
||||||
None => return Ok(()),
|
}
|
||||||
};
|
let object_ap_id = self.object_ap_id(thought_id).await?;
|
||||||
let object_ap_id = self.object_ap_id(&thought, thought_id);
|
|
||||||
self.ap
|
self.ap
|
||||||
.broadcast_undo_announce(user_id, &object_ap_id)
|
.broadcast_undo_announce(user_id, &object_ap_id)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
DomainEvent::FetchRemoteActorPosts {
|
|
||||||
actor_ap_url,
|
|
||||||
outbox_url,
|
|
||||||
} => {
|
|
||||||
let notes = match self
|
|
||||||
.federation_action
|
|
||||||
.fetch_outbox_page(outbox_url, 1)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(n) => n,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(outbox_url, error = %e, "failed to fetch remote outbox");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let actor_url = url::Url::parse(actor_ap_url)
|
|
||||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
|
||||||
|
|
||||||
let author_id = self.ap_repo.intern_remote_actor(&actor_url).await?;
|
|
||||||
|
|
||||||
// Resolve and cache display info so thought cards show proper names.
|
|
||||||
let profiles = self
|
|
||||||
.federation_action
|
|
||||||
.resolve_actor_profiles(vec![actor_ap_url.clone()])
|
|
||||||
.await;
|
|
||||||
if let Some(profile) = profiles.into_iter().next() {
|
|
||||||
let _ = self
|
|
||||||
.ap_repo
|
|
||||||
.update_remote_actor_display(
|
|
||||||
&author_id,
|
|
||||||
profile.display_name.as_deref(),
|
|
||||||
profile.avatar_url.as_deref(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
for note in notes {
|
|
||||||
let ap_id = match url::Url::parse(¬e.ap_id) {
|
|
||||||
Ok(u) => u,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
let _ = self
|
|
||||||
.ap_repo
|
|
||||||
.accept_note(
|
|
||||||
&ap_id,
|
|
||||||
&author_id,
|
|
||||||
¬e.content,
|
|
||||||
note.published,
|
|
||||||
note.sensitive,
|
|
||||||
note.content_warning,
|
|
||||||
"public",
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
DomainEvent::FetchActorConnections {
|
|
||||||
actor_ap_url,
|
|
||||||
collection_url,
|
|
||||||
connection_type,
|
|
||||||
page,
|
|
||||||
} => {
|
|
||||||
let urls = match self
|
|
||||||
.federation_action
|
|
||||||
.fetch_actor_urls_from_collection(collection_url)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(u) => u,
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
collection_url,
|
|
||||||
error = %e,
|
|
||||||
"failed to fetch actor connections collection"
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if urls.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let summaries = self.federation_action.resolve_actor_profiles(urls).await;
|
|
||||||
|
|
||||||
if summaries.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
count = summaries.len(),
|
|
||||||
connection_type,
|
|
||||||
actor = actor_ap_url,
|
|
||||||
"caching actor connections"
|
|
||||||
);
|
|
||||||
|
|
||||||
self.remote_actor_connections
|
|
||||||
.upsert_connections(actor_ap_url, connection_type, *page, &summaries)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
DomainEvent::LikeAdded {
|
DomainEvent::LikeAdded {
|
||||||
like_id: _,
|
like_id: _,
|
||||||
user_id,
|
user_id,
|
||||||
@@ -262,17 +158,19 @@ impl FederationEventService {
|
|||||||
};
|
};
|
||||||
let _ = liker;
|
let _ = liker;
|
||||||
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||||
Some(t) if t.ap_id.is_some() => t,
|
Some(t) => t,
|
||||||
_ => return Ok(()),
|
_ => return Ok(()),
|
||||||
};
|
};
|
||||||
let author = match self.users.find_by_id(&thought.user_id).await? {
|
let thought_ap_id = match self.ap_repo.get_thought_ap_id(thought_id).await? {
|
||||||
Some(u) if u.inbox_url.is_some() => u,
|
Some(id) => id,
|
||||||
_ => return Ok(()),
|
None => return Ok(()), // local thought — no federation needed
|
||||||
|
};
|
||||||
|
let actor_urls = match self.ap_repo.get_actor_ap_urls(&thought.user_id).await? {
|
||||||
|
Some(u) => u,
|
||||||
|
None => return Ok(()),
|
||||||
};
|
};
|
||||||
let object_ap_id = thought.ap_id.unwrap();
|
|
||||||
let inbox_url = author.inbox_url.unwrap();
|
|
||||||
self.ap
|
self.ap
|
||||||
.broadcast_like(user_id, &object_ap_id, &inbox_url)
|
.broadcast_like(user_id, &thought_ap_id, &actor_urls.inbox_url)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,17 +184,19 @@ impl FederationEventService {
|
|||||||
};
|
};
|
||||||
let _ = liker;
|
let _ = liker;
|
||||||
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||||
Some(t) if t.ap_id.is_some() => t,
|
Some(t) => t,
|
||||||
_ => return Ok(()),
|
_ => return Ok(()),
|
||||||
};
|
};
|
||||||
let author = match self.users.find_by_id(&thought.user_id).await? {
|
let thought_ap_id = match self.ap_repo.get_thought_ap_id(thought_id).await? {
|
||||||
Some(u) if u.inbox_url.is_some() => u,
|
Some(id) => id,
|
||||||
_ => return Ok(()),
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
let actor_urls = match self.ap_repo.get_actor_ap_urls(&thought.user_id).await? {
|
||||||
|
Some(u) => u,
|
||||||
|
None => return Ok(()),
|
||||||
};
|
};
|
||||||
let object_ap_id = thought.ap_id.unwrap();
|
|
||||||
let inbox_url = author.inbox_url.unwrap();
|
|
||||||
self.ap
|
self.ap
|
||||||
.broadcast_undo_like(user_id, &object_ap_id, &inbox_url)
|
.broadcast_undo_like(user_id, &thought_ap_id, &actor_urls.inbox_url)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +245,7 @@ mod tests {
|
|||||||
_: &UserId,
|
_: &UserId,
|
||||||
thought: &Thought,
|
thought: &Thought,
|
||||||
_: &str,
|
_: &str,
|
||||||
|
_in_reply_to_url: Option<&str>,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
self.created.lock().unwrap().push(thought.id.clone());
|
self.created.lock().unwrap().push(thought.id.clone());
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -358,6 +259,7 @@ mod tests {
|
|||||||
_: &UserId,
|
_: &UserId,
|
||||||
thought: &Thought,
|
thought: &Thought,
|
||||||
_: &str,
|
_: &str,
|
||||||
|
_in_reply_to_url: Option<&str>,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
self.updated.lock().unwrap().push(thought.id.clone());
|
self.updated.lock().unwrap().push(thought.id.clone());
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -428,9 +330,7 @@ mod tests {
|
|||||||
users: Arc::new(store.clone()),
|
users: Arc::new(store.clone()),
|
||||||
ap: spy,
|
ap: spy,
|
||||||
base_url: "https://example.com".to_string(),
|
base_url: "https://example.com".to_string(),
|
||||||
federation_action: Arc::new(store.clone()),
|
|
||||||
ap_repo: Arc::new(store.clone()),
|
ap_repo: Arc::new(store.clone()),
|
||||||
remote_actor_connections: Arc::new(store.clone()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,10 +360,9 @@ mod tests {
|
|||||||
async fn remote_thought_created_does_not_broadcast() {
|
async fn remote_thought_created_does_not_broadcast() {
|
||||||
let store = TestStore::default();
|
let store = TestStore::default();
|
||||||
let alice = alice();
|
let alice = alice();
|
||||||
// Remote thought: local = false, ap_id = Some(...)
|
// Remote thought: local = false
|
||||||
let mut thought = local_thought(alice.id.clone());
|
let mut thought = local_thought(alice.id.clone());
|
||||||
thought.local = false;
|
thought.local = false;
|
||||||
thought.ap_id = Some("https://remote.example/notes/1".into());
|
|
||||||
store.users.lock().unwrap().push(alice.clone());
|
store.users.lock().unwrap().push(alice.clone());
|
||||||
store.thoughts.lock().unwrap().push(thought.clone());
|
store.thoughts.lock().unwrap().push(thought.clone());
|
||||||
|
|
||||||
@@ -553,7 +452,10 @@ mod tests {
|
|||||||
let alice = alice();
|
let alice = alice();
|
||||||
let mut thought = local_thought(alice.id.clone());
|
let mut thought = local_thought(alice.id.clone());
|
||||||
thought.local = false;
|
thought.local = false;
|
||||||
thought.ap_id = Some("https://mastodon.social/users/bob/statuses/123".into());
|
store.thought_ap_ids.lock().unwrap().insert(
|
||||||
|
thought.id.clone(),
|
||||||
|
"https://mastodon.social/users/bob/statuses/123".into(),
|
||||||
|
);
|
||||||
store.users.lock().unwrap().push(alice.clone());
|
store.users.lock().unwrap().push(alice.clone());
|
||||||
store.thoughts.lock().unwrap().push(thought.clone());
|
store.thoughts.lock().unwrap().push(thought.clone());
|
||||||
|
|
||||||
@@ -702,7 +604,10 @@ mod tests {
|
|||||||
let alice = alice();
|
let alice = alice();
|
||||||
let mut thought = local_thought(alice.id.clone());
|
let mut thought = local_thought(alice.id.clone());
|
||||||
thought.local = false;
|
thought.local = false;
|
||||||
thought.ap_id = Some("https://mastodon.social/users/bob/statuses/456".into());
|
store.thought_ap_ids.lock().unwrap().insert(
|
||||||
|
thought.id.clone(),
|
||||||
|
"https://mastodon.social/users/bob/statuses/456".into(),
|
||||||
|
);
|
||||||
store.thoughts.lock().unwrap().push(thought.clone());
|
store.thoughts.lock().unwrap().push(thought.clone());
|
||||||
|
|
||||||
let spy = Arc::new(SpyPort::default());
|
let spy = Arc::new(SpyPort::default());
|
||||||
@@ -757,35 +662,6 @@ mod tests {
|
|||||||
assert!(spy.updated.lock().unwrap().is_empty());
|
assert!(spy.updated.lock().unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn fetch_remote_actor_posts_is_noop_when_outbox_empty() {
|
|
||||||
let store = TestStore::default();
|
|
||||||
let spy = Arc::new(SpyPort::default());
|
|
||||||
svc(&store, spy.clone())
|
|
||||||
.process(&DomainEvent::FetchRemoteActorPosts {
|
|
||||||
actor_ap_url: "https://mastodon.social/users/alice".into(),
|
|
||||||
outbox_url: "https://mastodon.social/users/alice/outbox".into(),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
// TestStore.fetch_outbox_page returns Ok(vec![]) — no notes, no error
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn fetch_actor_connections_is_noop_when_collection_empty() {
|
|
||||||
let store = TestStore::default();
|
|
||||||
let spy = Arc::new(SpyPort::default());
|
|
||||||
svc(&store, spy.clone())
|
|
||||||
.process(&DomainEvent::FetchActorConnections {
|
|
||||||
actor_ap_url: "https://mastodon.social/users/alice".into(),
|
|
||||||
collection_url: "https://mastodon.social/users/alice/followers".into(),
|
|
||||||
connection_type: "followers".into(),
|
|
||||||
page: 1,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn like_added_local_user_remote_thought_broadcasts_like() {
|
async fn like_added_local_user_remote_thought_broadcasts_like() {
|
||||||
let store = TestStore::default();
|
let store = TestStore::default();
|
||||||
@@ -797,10 +673,19 @@ mod tests {
|
|||||||
PasswordHash("h".into()),
|
PasswordHash("h".into()),
|
||||||
);
|
);
|
||||||
author.local = false;
|
author.local = false;
|
||||||
author.inbox_url = Some("https://mastodon.social/users/author/inbox".into());
|
store.actor_ap_urls.lock().unwrap().insert(
|
||||||
|
author.id.clone(),
|
||||||
|
domain::ports::ActorApUrls {
|
||||||
|
ap_id: "https://mastodon.social/users/author".into(),
|
||||||
|
inbox_url: "https://mastodon.social/users/author/inbox".into(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
let mut thought = local_thought(author.id.clone());
|
let thought = local_thought(author.id.clone());
|
||||||
thought.ap_id = Some("https://mastodon.social/posts/123".into());
|
store.thought_ap_ids.lock().unwrap().insert(
|
||||||
|
thought.id.clone(),
|
||||||
|
"https://mastodon.social/posts/123".into(),
|
||||||
|
);
|
||||||
|
|
||||||
let liker = alice();
|
let liker = alice();
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ use chrono::Utc;
|
|||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::notification::{Notification, NotificationType},
|
models::notification::{Notification, NotificationKind},
|
||||||
ports::{NotificationRepository, ThoughtRepository},
|
ports::{NotificationRepository, ThoughtRepository},
|
||||||
value_objects::{NotificationId, UserId},
|
value_objects::NotificationId,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -13,7 +13,10 @@ pub struct NotificationEventService {
|
|||||||
pub notifications: Arc<dyn NotificationRepository>,
|
pub notifications: Arc<dyn NotificationRepository>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_self_action(thought_author: &UserId, actor: &UserId) -> bool {
|
fn is_self_action(
|
||||||
|
thought_author: &domain::value_objects::UserId,
|
||||||
|
actor: &domain::value_objects::UserId,
|
||||||
|
) -> bool {
|
||||||
thought_author == actor
|
thought_author == actor
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,9 +39,10 @@ impl NotificationEventService {
|
|||||||
.save(&Notification {
|
.save(&Notification {
|
||||||
id: NotificationId::new(),
|
id: NotificationId::new(),
|
||||||
user_id: thought.user_id,
|
user_id: thought.user_id,
|
||||||
notification_type: NotificationType::Like,
|
kind: NotificationKind::Like {
|
||||||
from_user_id: Some(user_id.clone()),
|
thought_id: thought_id.clone(),
|
||||||
thought_id: Some(thought_id.clone()),
|
from_user_id: user_id.clone(),
|
||||||
|
},
|
||||||
read: false,
|
read: false,
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
})
|
})
|
||||||
@@ -60,9 +64,10 @@ impl NotificationEventService {
|
|||||||
.save(&Notification {
|
.save(&Notification {
|
||||||
id: NotificationId::new(),
|
id: NotificationId::new(),
|
||||||
user_id: thought.user_id,
|
user_id: thought.user_id,
|
||||||
notification_type: NotificationType::Boost,
|
kind: NotificationKind::Boost {
|
||||||
from_user_id: Some(user_id.clone()),
|
thought_id: thought_id.clone(),
|
||||||
thought_id: Some(thought_id.clone()),
|
from_user_id: user_id.clone(),
|
||||||
|
},
|
||||||
read: false,
|
read: false,
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
})
|
})
|
||||||
@@ -76,9 +81,9 @@ impl NotificationEventService {
|
|||||||
.save(&Notification {
|
.save(&Notification {
|
||||||
id: NotificationId::new(),
|
id: NotificationId::new(),
|
||||||
user_id: following_id.clone(),
|
user_id: following_id.clone(),
|
||||||
notification_type: NotificationType::Follow,
|
kind: NotificationKind::Follow {
|
||||||
from_user_id: Some(follower_id.clone()),
|
from_user_id: follower_id.clone(),
|
||||||
thought_id: None,
|
},
|
||||||
read: false,
|
read: false,
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
})
|
})
|
||||||
@@ -104,9 +109,10 @@ impl NotificationEventService {
|
|||||||
.save(&Notification {
|
.save(&Notification {
|
||||||
id: NotificationId::new(),
|
id: NotificationId::new(),
|
||||||
user_id: original.user_id,
|
user_id: original.user_id,
|
||||||
notification_type: NotificationType::Reply,
|
kind: NotificationKind::Reply {
|
||||||
from_user_id: Some(user_id.clone()),
|
thought_id: thought_id.clone(),
|
||||||
thought_id: Some(thought_id.clone()),
|
from_user_id: user_id.clone(),
|
||||||
|
},
|
||||||
read: false,
|
read: false,
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
})
|
})
|
||||||
@@ -121,9 +127,10 @@ impl NotificationEventService {
|
|||||||
.save(&Notification {
|
.save(&Notification {
|
||||||
id: NotificationId::new(),
|
id: NotificationId::new(),
|
||||||
user_id: mentioned_user_id.clone(),
|
user_id: mentioned_user_id.clone(),
|
||||||
notification_type: NotificationType::Mention,
|
kind: NotificationKind::Mention {
|
||||||
from_user_id: Some(author_user_id.clone()),
|
thought_id: thought_id.clone(),
|
||||||
thought_id: Some(thought_id.clone()),
|
from_user_id: author_user_id.clone(),
|
||||||
|
},
|
||||||
read: false,
|
read: false,
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
})
|
})
|
||||||
@@ -139,6 +146,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use domain::{
|
use domain::{
|
||||||
models::{
|
models::{
|
||||||
|
notification::NotificationKind,
|
||||||
thought::{Thought, Visibility},
|
thought::{Thought, Visibility},
|
||||||
user::User,
|
user::User,
|
||||||
},
|
},
|
||||||
@@ -184,10 +192,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let notifs = store.notifications.lock().unwrap();
|
let notifs = store.notifications.lock().unwrap();
|
||||||
assert_eq!(notifs.len(), 1);
|
assert_eq!(notifs.len(), 1);
|
||||||
assert!(matches!(
|
assert!(matches!(notifs[0].kind, NotificationKind::Like { .. }));
|
||||||
notifs[0].notification_type,
|
|
||||||
NotificationType::Like
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -235,10 +240,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let notifs = store.notifications.lock().unwrap();
|
let notifs = store.notifications.lock().unwrap();
|
||||||
assert_eq!(notifs.len(), 1);
|
assert_eq!(notifs.len(), 1);
|
||||||
assert!(matches!(
|
assert!(matches!(notifs[0].kind, NotificationKind::Follow { .. }));
|
||||||
notifs[0].notification_type,
|
|
||||||
NotificationType::Follow
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -269,10 +271,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let notifs = store.notifications.lock().unwrap();
|
let notifs = store.notifications.lock().unwrap();
|
||||||
assert_eq!(notifs.len(), 1);
|
assert_eq!(notifs.len(), 1);
|
||||||
assert!(matches!(
|
assert!(matches!(notifs[0].kind, NotificationKind::Reply { .. }));
|
||||||
notifs[0].notification_type,
|
|
||||||
NotificationType::Reply
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use domain::{
|
|||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::user::User,
|
models::user::User,
|
||||||
ports::{AuthService, EventPublisher, PasswordHasher, UserRepository},
|
ports::{AuthService, EventPublisher, PasswordHasher, UserReader, UserRepository},
|
||||||
value_objects::{Email, UserId, Username},
|
value_objects::{Email, UserId, Username},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ pub struct LoginOutput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn login(
|
pub async fn login(
|
||||||
users: &dyn UserRepository,
|
users: &dyn UserReader,
|
||||||
hasher: &dyn PasswordHasher,
|
hasher: &dyn PasswordHasher,
|
||||||
auth: &dyn AuthService,
|
auth: &dyn AuthService,
|
||||||
input: LoginInput,
|
input: LoginInput,
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
|
||||||
models::{
|
models::{
|
||||||
actor_connection_summary::ActorConnectionSummary,
|
actor_connection_summary::ActorConnectionSummary,
|
||||||
feed::{FeedEntry, PageParams, Paginated},
|
feed::{FeedEntry, PageParams, Paginated},
|
||||||
remote_actor::RemoteActor,
|
remote_actor::RemoteActor,
|
||||||
},
|
},
|
||||||
ports::{
|
ports::{
|
||||||
ActivityPubRepository, EventPublisher, FederationActionPort, FeedRepository,
|
ActivityPubRepository, EventPublisher, FederationActionPort, FederationFollowPort,
|
||||||
FollowRepository, RemoteActorConnectionRepository, UserRepository,
|
FederationFollowRequestPort, FederationSchedulerPort, FeedRepository, FollowRepository,
|
||||||
|
RemoteActorConnectionRepository, UserReader,
|
||||||
},
|
},
|
||||||
value_objects::UserId,
|
value_objects::UserId,
|
||||||
};
|
};
|
||||||
@@ -16,14 +16,14 @@ use domain::{
|
|||||||
use super::social;
|
use super::social;
|
||||||
|
|
||||||
pub async fn list_pending_requests(
|
pub async fn list_pending_requests(
|
||||||
federation: &dyn FederationActionPort,
|
federation: &dyn FederationFollowRequestPort,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
) -> Result<Vec<RemoteActor>, DomainError> {
|
) -> Result<Vec<RemoteActor>, DomainError> {
|
||||||
federation.get_pending_followers(user_id).await
|
federation.get_pending_followers(user_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn accept_follow_request(
|
pub async fn accept_follow_request(
|
||||||
federation: &dyn FederationActionPort,
|
federation: &dyn FederationFollowRequestPort,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
actor_url: &str,
|
actor_url: &str,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
@@ -31,7 +31,7 @@ pub async fn accept_follow_request(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn reject_follow_request(
|
pub async fn reject_follow_request(
|
||||||
federation: &dyn FederationActionPort,
|
federation: &dyn FederationFollowRequestPort,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
actor_url: &str,
|
actor_url: &str,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
@@ -39,14 +39,14 @@ pub async fn reject_follow_request(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_remote_followers(
|
pub async fn list_remote_followers(
|
||||||
federation: &dyn FederationActionPort,
|
federation: &dyn FederationFollowRequestPort,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
) -> Result<Vec<RemoteActor>, DomainError> {
|
) -> Result<Vec<RemoteActor>, DomainError> {
|
||||||
federation.get_remote_followers(user_id).await
|
federation.get_remote_followers(user_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn remove_remote_follower(
|
pub async fn remove_remote_follower(
|
||||||
federation: &dyn FederationActionPort,
|
federation: &dyn FederationFollowRequestPort,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
actor_url: &str,
|
actor_url: &str,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
@@ -54,7 +54,7 @@ pub async fn remove_remote_follower(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_remote_following(
|
pub async fn list_remote_following(
|
||||||
federation: &dyn FederationActionPort,
|
federation: &dyn FederationFollowPort,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
) -> Result<Vec<RemoteActor>, DomainError> {
|
) -> Result<Vec<RemoteActor>, DomainError> {
|
||||||
federation.get_remote_following(user_id).await
|
federation.get_remote_following(user_id).await
|
||||||
@@ -62,8 +62,8 @@ pub async fn list_remote_following(
|
|||||||
|
|
||||||
pub async fn remove_remote_following(
|
pub async fn remove_remote_following(
|
||||||
follows: &dyn FollowRepository,
|
follows: &dyn FollowRepository,
|
||||||
users: &dyn UserRepository,
|
users: &dyn UserReader,
|
||||||
federation: &dyn FederationActionPort,
|
federation: &dyn FederationFollowPort,
|
||||||
events: &dyn EventPublisher,
|
events: &dyn EventPublisher,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
handle: &str,
|
handle: &str,
|
||||||
@@ -75,24 +75,20 @@ pub async fn get_remote_actor_posts(
|
|||||||
federation: &dyn FederationActionPort,
|
federation: &dyn FederationActionPort,
|
||||||
ap_repo: &dyn ActivityPubRepository,
|
ap_repo: &dyn ActivityPubRepository,
|
||||||
feed: &dyn FeedRepository,
|
feed: &dyn FeedRepository,
|
||||||
events: &dyn EventPublisher,
|
scheduler: &dyn FederationSchedulerPort,
|
||||||
handle: &str,
|
handle: &str,
|
||||||
page: PageParams,
|
page: PageParams,
|
||||||
viewer_id: Option<&UserId>,
|
viewer_id: Option<&UserId>,
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
let actor = federation.lookup_actor(handle).await?;
|
let actor = federation.lookup_actor(handle).await?;
|
||||||
let ap_url = url::Url::parse(&actor.url).map_err(|e| DomainError::Internal(e.to_string()))?;
|
let author_id = match ap_repo.find_remote_actor_id(&actor.url).await? {
|
||||||
let author_id = match ap_repo.find_remote_actor_id(&ap_url).await? {
|
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => ap_repo.intern_remote_actor(&ap_url).await?,
|
None => ap_repo.intern_remote_actor(&actor.url).await?,
|
||||||
};
|
};
|
||||||
let result = feed.user_feed(&author_id, &page, viewer_id).await?;
|
let result = feed.user_feed(&author_id, &page, viewer_id).await?;
|
||||||
if let Some(outbox_url) = actor.outbox_url {
|
if let Some(outbox_url) = actor.outbox_url {
|
||||||
let _ = events
|
let _ = scheduler
|
||||||
.publish(&DomainEvent::FetchRemoteActorPosts {
|
.schedule_actor_posts_fetch(&actor.url, &outbox_url)
|
||||||
actor_ap_url: actor.url,
|
|
||||||
outbox_url,
|
|
||||||
})
|
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
Ok(result)
|
Ok(result)
|
||||||
@@ -103,7 +99,7 @@ const ACTOR_CONNECTIONS_CACHE_TTL_SECS: i64 = 3600;
|
|||||||
pub async fn get_actor_connections_page(
|
pub async fn get_actor_connections_page(
|
||||||
federation: &dyn FederationActionPort,
|
federation: &dyn FederationActionPort,
|
||||||
connections: &dyn RemoteActorConnectionRepository,
|
connections: &dyn RemoteActorConnectionRepository,
|
||||||
events: &dyn EventPublisher,
|
scheduler: &dyn FederationSchedulerPort,
|
||||||
handle: &str,
|
handle: &str,
|
||||||
connection_type: &str,
|
connection_type: &str,
|
||||||
page: u32,
|
page: u32,
|
||||||
@@ -128,13 +124,8 @@ pub async fn get_actor_connections_page(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if stale {
|
if stale {
|
||||||
let _ = events
|
let _ = scheduler
|
||||||
.publish(&DomainEvent::FetchActorConnections {
|
.schedule_connections_fetch(&actor.url, &collection_url, connection_type, page)
|
||||||
actor_ap_url: actor.url,
|
|
||||||
collection_url,
|
|
||||||
connection_type: connection_type.to_string(),
|
|
||||||
page,
|
|
||||||
})
|
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
let has_more = items.len() >= PAGE_SIZE;
|
let has_more = items.len() >= PAGE_SIZE;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use domain::{
|
|||||||
feed::{FeedEntry, PageParams, Paginated, UserSummary},
|
feed::{FeedEntry, PageParams, Paginated, UserSummary},
|
||||||
user::User,
|
user::User,
|
||||||
},
|
},
|
||||||
ports::{FeedRepository, FollowRepository, TagRepository, UserRepository},
|
ports::{FeedRepository, FollowRepository, TagRepository, UserReader},
|
||||||
value_objects::UserId,
|
value_objects::UserId,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,12 +70,12 @@ pub async fn search(
|
|||||||
feed.search(query, &page, viewer_id).await
|
feed.search(query, &page, viewer_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_users(users: &dyn UserRepository) -> Result<Vec<UserSummary>, DomainError> {
|
pub async fn list_users(users: &dyn UserReader) -> Result<Vec<UserSummary>, DomainError> {
|
||||||
users.list_with_stats().await
|
users.list_with_stats().await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_users_paginated(
|
pub async fn list_users_paginated(
|
||||||
users: &dyn UserRepository,
|
users: &dyn UserReader,
|
||||||
page: PageParams,
|
page: PageParams,
|
||||||
) -> Result<Paginated<UserSummary>, DomainError> {
|
) -> Result<Paginated<UserSummary>, DomainError> {
|
||||||
let all = users.list_with_stats().await?;
|
let all = users.list_with_stats().await?;
|
||||||
|
|||||||
@@ -4,6 +4,5 @@ pub mod federation_management;
|
|||||||
pub mod feed;
|
pub mod feed;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod search;
|
|
||||||
pub mod social;
|
pub mod social;
|
||||||
pub mod thoughts;
|
pub mod thoughts;
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ use domain::{
|
|||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::{top_friend::TopFriend, user::User},
|
models::{top_friend::TopFriend, user::User},
|
||||||
ports::{EventPublisher, TopFriendRepository, UserRepository},
|
ports::{EventPublisher, TopFriendRepository, UserReader, UserWriter},
|
||||||
value_objects::{UserId, Username},
|
value_objects::{UserId, Username},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn get_user(users: &dyn UserRepository, user_id: &UserId) -> Result<User, DomainError> {
|
pub async fn get_user(users: &dyn UserReader, user_id: &UserId) -> Result<User, DomainError> {
|
||||||
users
|
users
|
||||||
.find_by_id(user_id)
|
.find_by_id(user_id)
|
||||||
.await?
|
.await?
|
||||||
@@ -16,7 +16,7 @@ pub async fn get_user(users: &dyn UserRepository, user_id: &UserId) -> Result<Us
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_user_by_username(
|
pub async fn get_user_by_username(
|
||||||
users: &dyn UserRepository,
|
users: &dyn UserReader,
|
||||||
username: &str,
|
username: &str,
|
||||||
) -> Result<User, DomainError> {
|
) -> Result<User, DomainError> {
|
||||||
let username = Username::new(username).map_err(|_| DomainError::NotFound)?;
|
let username = Username::new(username).map_err(|_| DomainError::NotFound)?;
|
||||||
@@ -28,7 +28,7 @@ pub async fn get_user_by_username(
|
|||||||
|
|
||||||
/// Resolve a path segment that is either a UUID (AP actor URL) or a username.
|
/// Resolve a path segment that is either a UUID (AP actor URL) or a username.
|
||||||
pub async fn get_user_by_id_or_username(
|
pub async fn get_user_by_id_or_username(
|
||||||
users: &dyn UserRepository,
|
users: &dyn UserReader,
|
||||||
id_or_username: &str,
|
id_or_username: &str,
|
||||||
) -> Result<User, DomainError> {
|
) -> Result<User, DomainError> {
|
||||||
if let Ok(uuid) = uuid::Uuid::parse_str(id_or_username) {
|
if let Ok(uuid) = uuid::Uuid::parse_str(id_or_username) {
|
||||||
@@ -43,7 +43,7 @@ pub async fn get_user_by_id_or_username(
|
|||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn update_profile(
|
pub async fn update_profile(
|
||||||
users: &dyn UserRepository,
|
users: &dyn UserWriter,
|
||||||
events: &dyn EventPublisher,
|
events: &dyn EventPublisher,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
display_name: Option<String>,
|
display_name: Option<String>,
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
use domain::{
|
|
||||||
errors::DomainError,
|
|
||||||
models::{
|
|
||||||
feed::{FeedEntry, PageParams, Paginated},
|
|
||||||
user::User,
|
|
||||||
},
|
|
||||||
ports::SearchPort,
|
|
||||||
value_objects::UserId,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub async fn search_thoughts(
|
|
||||||
search: &dyn SearchPort,
|
|
||||||
query: &str,
|
|
||||||
page: PageParams,
|
|
||||||
viewer_id: Option<&UserId>,
|
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
|
||||||
search.search_thoughts(query, &page, viewer_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn search_users(
|
|
||||||
search: &dyn SearchPort,
|
|
||||||
query: &str,
|
|
||||||
page: PageParams,
|
|
||||||
) -> Result<Paginated<User>, DomainError> {
|
|
||||||
search.search_users(query, &page).await
|
|
||||||
}
|
|
||||||
@@ -4,8 +4,8 @@ use domain::{
|
|||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::social::{Block, Boost, Follow, FollowState, Like},
|
models::social::{Block, Boost, Follow, FollowState, Like},
|
||||||
ports::{
|
ports::{
|
||||||
BlockRepository, BoostRepository, EventPublisher, FederationActionPort, FollowRepository,
|
BlockRepository, BoostRepository, EventPublisher, FederationFollowPort, FollowRepository,
|
||||||
LikeRepository, UserRepository,
|
LikeRepository, UserReader,
|
||||||
},
|
},
|
||||||
value_objects::{BoostId, LikeId, ThoughtId, UserId, Username},
|
value_objects::{BoostId, LikeId, ThoughtId, UserId, Username},
|
||||||
};
|
};
|
||||||
@@ -92,8 +92,8 @@ pub async fn unboost_thought(
|
|||||||
|
|
||||||
pub async fn follow_actor(
|
pub async fn follow_actor(
|
||||||
follows: &dyn FollowRepository,
|
follows: &dyn FollowRepository,
|
||||||
users: &dyn UserRepository,
|
users: &dyn UserReader,
|
||||||
federation: &dyn FederationActionPort,
|
federation: &dyn FederationFollowPort,
|
||||||
events: &dyn EventPublisher,
|
events: &dyn EventPublisher,
|
||||||
follower_id: &UserId,
|
follower_id: &UserId,
|
||||||
username: &str,
|
username: &str,
|
||||||
@@ -139,8 +139,8 @@ pub async fn follow_user(
|
|||||||
|
|
||||||
pub async fn unfollow_actor(
|
pub async fn unfollow_actor(
|
||||||
follows: &dyn FollowRepository,
|
follows: &dyn FollowRepository,
|
||||||
users: &dyn UserRepository,
|
users: &dyn UserReader,
|
||||||
federation: &dyn FederationActionPort,
|
federation: &dyn FederationFollowPort,
|
||||||
events: &dyn EventPublisher,
|
events: &dyn EventPublisher,
|
||||||
follower_id: &UserId,
|
follower_id: &UserId,
|
||||||
username: &str,
|
username: &str,
|
||||||
@@ -212,7 +212,7 @@ pub async fn reject_follow(
|
|||||||
|
|
||||||
pub async fn block_by_username(
|
pub async fn block_by_username(
|
||||||
blocks: &dyn BlockRepository,
|
blocks: &dyn BlockRepository,
|
||||||
users: &dyn UserRepository,
|
users: &dyn UserReader,
|
||||||
events: &dyn EventPublisher,
|
events: &dyn EventPublisher,
|
||||||
blocker_id: &UserId,
|
blocker_id: &UserId,
|
||||||
username: &str,
|
username: &str,
|
||||||
@@ -227,7 +227,7 @@ pub async fn block_by_username(
|
|||||||
|
|
||||||
pub async fn unblock_by_username(
|
pub async fn unblock_by_username(
|
||||||
blocks: &dyn BlockRepository,
|
blocks: &dyn BlockRepository,
|
||||||
users: &dyn UserRepository,
|
users: &dyn UserReader,
|
||||||
events: &dyn EventPublisher,
|
events: &dyn EventPublisher,
|
||||||
blocker_id: &UserId,
|
blocker_id: &UserId,
|
||||||
username: &str,
|
username: &str,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use domain::{
|
|||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::thought::{Thought, Visibility},
|
models::thought::{Thought, Visibility},
|
||||||
ports::{EventPublisher, TagRepository, ThoughtRepository, UserRepository},
|
ports::{EventPublisher, TagRepository, ThoughtRepository, UserReader},
|
||||||
value_objects::{Content, ThoughtId, UserId},
|
value_objects::{Content, ThoughtId, UserId},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ pub struct CreateThoughtOutput {
|
|||||||
|
|
||||||
pub async fn create_thought(
|
pub async fn create_thought(
|
||||||
thoughts: &dyn ThoughtRepository,
|
thoughts: &dyn ThoughtRepository,
|
||||||
_users: &dyn UserRepository,
|
_users: &dyn UserReader,
|
||||||
tags: &dyn TagRepository,
|
tags: &dyn TagRepository,
|
||||||
events: &dyn EventPublisher,
|
events: &dyn EventPublisher,
|
||||||
input: CreateThoughtInput,
|
input: CreateThoughtInput,
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ pub async fn build(cfg: &Config) -> Infrastructure {
|
|||||||
federation: ap_service.clone() as Arc<dyn domain::ports::FederationActionPort>,
|
federation: ap_service.clone() as Arc<dyn domain::ports::FederationActionPort>,
|
||||||
ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())),
|
ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())),
|
||||||
remote_actor_connections: Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())),
|
remote_actor_connections: Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())),
|
||||||
|
federation_scheduler: ap_service.clone() as Arc<dyn domain::ports::FederationSchedulerPort>,
|
||||||
};
|
};
|
||||||
|
|
||||||
Infrastructure { state, ap_service }
|
Infrastructure { state, ap_service }
|
||||||
|
|||||||
@@ -63,16 +63,6 @@ pub enum DomainEvent {
|
|||||||
ProfileUpdated {
|
ProfileUpdated {
|
||||||
user_id: UserId,
|
user_id: UserId,
|
||||||
},
|
},
|
||||||
FetchRemoteActorPosts {
|
|
||||||
actor_ap_url: String,
|
|
||||||
outbox_url: String,
|
|
||||||
},
|
|
||||||
FetchActorConnections {
|
|
||||||
actor_ap_url: String,
|
|
||||||
collection_url: String,
|
|
||||||
connection_type: String,
|
|
||||||
page: u32,
|
|
||||||
},
|
|
||||||
MentionReceived {
|
MentionReceived {
|
||||||
thought_id: ThoughtId,
|
thought_id: ThoughtId,
|
||||||
mentioned_user_id: UserId,
|
mentioned_user_id: UserId,
|
||||||
|
|||||||
@@ -1,6 +1,30 @@
|
|||||||
use crate::models::{thought::Thought, user::User};
|
use crate::models::{thought::Thought, user::User};
|
||||||
use crate::value_objects::UserId;
|
use crate::value_objects::UserId;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EngagementStats {
|
||||||
|
pub like_count: i64,
|
||||||
|
pub boost_count: i64,
|
||||||
|
pub reply_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Present only when an authenticated viewer made the request.
|
||||||
|
/// `liked`/`boosted` are the viewer's interaction state with this thought.
|
||||||
|
/// `None` means anonymous request or viewer context unavailable.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ViewerContext {
|
||||||
|
pub liked: bool,
|
||||||
|
pub boosted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FeedEntry {
|
||||||
|
pub thought: Thought,
|
||||||
|
pub author: User,
|
||||||
|
pub stats: EngagementStats,
|
||||||
|
pub viewer: Option<ViewerContext>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct UserSummary {
|
pub struct UserSummary {
|
||||||
pub id: UserId,
|
pub id: UserId,
|
||||||
@@ -13,17 +37,6 @@ pub struct UserSummary {
|
|||||||
pub following_count: i64,
|
pub following_count: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct FeedEntry {
|
|
||||||
pub thought: Thought,
|
|
||||||
pub author: User,
|
|
||||||
pub like_count: i64,
|
|
||||||
pub boost_count: i64,
|
|
||||||
pub reply_count: i64,
|
|
||||||
pub liked_by_viewer: bool,
|
|
||||||
pub boosted_by_viewer: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PageParams {
|
pub struct PageParams {
|
||||||
pub page: u64,
|
pub page: u64,
|
||||||
|
|||||||
@@ -1,22 +1,66 @@
|
|||||||
use crate::value_objects::{NotificationId, ThoughtId, UserId};
|
use crate::value_objects::{NotificationId, ThoughtId, UserId};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum NotificationType {
|
pub enum NotificationKind {
|
||||||
Like,
|
Like {
|
||||||
Boost,
|
thought_id: ThoughtId,
|
||||||
Follow,
|
from_user_id: UserId,
|
||||||
Mention,
|
},
|
||||||
Reply,
|
Boost {
|
||||||
|
thought_id: ThoughtId,
|
||||||
|
from_user_id: UserId,
|
||||||
|
},
|
||||||
|
Reply {
|
||||||
|
thought_id: ThoughtId,
|
||||||
|
from_user_id: UserId,
|
||||||
|
},
|
||||||
|
Mention {
|
||||||
|
thought_id: ThoughtId,
|
||||||
|
from_user_id: UserId,
|
||||||
|
},
|
||||||
|
Follow {
|
||||||
|
from_user_id: UserId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NotificationKind {
|
||||||
|
pub fn from_user_id(&self) -> &UserId {
|
||||||
|
match self {
|
||||||
|
Self::Like { from_user_id, .. } => from_user_id,
|
||||||
|
Self::Boost { from_user_id, .. } => from_user_id,
|
||||||
|
Self::Reply { from_user_id, .. } => from_user_id,
|
||||||
|
Self::Mention { from_user_id, .. } => from_user_id,
|
||||||
|
Self::Follow { from_user_id } => from_user_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn thought_id(&self) -> Option<&ThoughtId> {
|
||||||
|
match self {
|
||||||
|
Self::Like { thought_id, .. } => Some(thought_id),
|
||||||
|
Self::Boost { thought_id, .. } => Some(thought_id),
|
||||||
|
Self::Reply { thought_id, .. } => Some(thought_id),
|
||||||
|
Self::Mention { thought_id, .. } => Some(thought_id),
|
||||||
|
Self::Follow { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kind_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Like { .. } => "like",
|
||||||
|
Self::Boost { .. } => "boost",
|
||||||
|
Self::Reply { .. } => "reply",
|
||||||
|
Self::Mention { .. } => "mention",
|
||||||
|
Self::Follow { .. } => "follow",
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Notification {
|
pub struct Notification {
|
||||||
pub id: NotificationId,
|
pub id: NotificationId,
|
||||||
pub user_id: UserId,
|
pub user_id: UserId,
|
||||||
pub notification_type: NotificationType,
|
pub kind: NotificationKind,
|
||||||
pub from_user_id: Option<UserId>,
|
|
||||||
pub thought_id: Option<ThoughtId>,
|
|
||||||
pub read: bool,
|
pub read: bool,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,7 @@ pub struct RemoteActor {
|
|||||||
pub url: String,
|
pub url: String,
|
||||||
pub handle: String,
|
pub handle: String,
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
pub inbox_url: String,
|
|
||||||
pub shared_inbox_url: Option<String>,
|
|
||||||
pub public_key: String,
|
|
||||||
pub avatar_url: Option<String>,
|
pub avatar_url: Option<String>,
|
||||||
pub last_fetched_at: DateTime<Utc>,
|
|
||||||
pub bio: Option<String>,
|
pub bio: Option<String>,
|
||||||
pub banner_url: Option<String>,
|
pub banner_url: Option<String>,
|
||||||
pub also_known_as: Option<String>,
|
pub also_known_as: Option<String>,
|
||||||
@@ -17,4 +13,5 @@ pub struct RemoteActor {
|
|||||||
pub followers_url: Option<String>,
|
pub followers_url: Option<String>,
|
||||||
pub following_url: Option<String>,
|
pub following_url: Option<String>,
|
||||||
pub attachment: Vec<(String, String)>,
|
pub attachment: Vec<(String, String)>,
|
||||||
|
pub last_fetched_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,11 +35,14 @@ impl FollowState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_db_str(s: &str) -> Self {
|
pub fn from_db_str(s: &str) -> Result<Self, crate::errors::DomainError> {
|
||||||
match s {
|
match s {
|
||||||
"pending" => Self::Pending,
|
"pending" => Ok(Self::Pending),
|
||||||
"rejected" => Self::Rejected,
|
"accepted" => Ok(Self::Accepted),
|
||||||
_ => Self::Accepted,
|
"rejected" => Ok(Self::Rejected),
|
||||||
|
other => Err(crate::errors::DomainError::Internal(format!(
|
||||||
|
"unknown follow_state: '{other}'"
|
||||||
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ pub struct Thought {
|
|||||||
pub user_id: UserId,
|
pub user_id: UserId,
|
||||||
pub content: Content,
|
pub content: Content,
|
||||||
pub in_reply_to_id: Option<ThoughtId>,
|
pub in_reply_to_id: Option<ThoughtId>,
|
||||||
pub in_reply_to_url: Option<String>,
|
|
||||||
pub ap_id: Option<String>,
|
|
||||||
pub visibility: Visibility,
|
pub visibility: Visibility,
|
||||||
pub content_warning: Option<String>,
|
pub content_warning: Option<String>,
|
||||||
pub sensitive: bool,
|
pub sensitive: bool,
|
||||||
@@ -35,12 +33,15 @@ impl Visibility {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_db_str(s: &str) -> Self {
|
pub fn from_db_str(s: &str) -> Result<Self, crate::errors::DomainError> {
|
||||||
match s {
|
match s {
|
||||||
"followers" => Self::Followers,
|
"public" => Ok(Self::Public),
|
||||||
"unlisted" => Self::Unlisted,
|
"followers" => Ok(Self::Followers),
|
||||||
"direct" => Self::Direct,
|
"unlisted" => Ok(Self::Unlisted),
|
||||||
_ => Self::Public,
|
"direct" => Ok(Self::Direct),
|
||||||
|
other => Err(crate::errors::DomainError::Internal(format!(
|
||||||
|
"unknown visibility: '{other}'"
|
||||||
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,8 +61,6 @@ impl Thought {
|
|||||||
user_id,
|
user_id,
|
||||||
content,
|
content,
|
||||||
in_reply_to_id,
|
in_reply_to_id,
|
||||||
in_reply_to_url: None,
|
|
||||||
ap_id: None,
|
|
||||||
visibility,
|
visibility,
|
||||||
content_warning,
|
content_warning,
|
||||||
sensitive,
|
sensitive,
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ pub struct User {
|
|||||||
pub header_url: Option<String>,
|
pub header_url: Option<String>,
|
||||||
pub custom_css: Option<String>,
|
pub custom_css: Option<String>,
|
||||||
pub local: bool,
|
pub local: bool,
|
||||||
pub ap_id: Option<String>,
|
|
||||||
pub inbox_url: Option<String>,
|
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
@@ -38,8 +36,6 @@ impl User {
|
|||||||
header_url: None,
|
header_url: None,
|
||||||
custom_css: None,
|
custom_css: None,
|
||||||
local: true,
|
local: true,
|
||||||
ap_id: None,
|
|
||||||
inbox_url: None,
|
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,10 +45,16 @@ pub trait EventConsumer: Send + Sync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait UserRepository: Send + Sync {
|
pub trait UserReader: Send + Sync {
|
||||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
|
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
|
||||||
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError>;
|
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError>;
|
||||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
|
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
|
||||||
|
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError>;
|
||||||
|
async fn count(&self) -> Result<i64, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait UserWriter: Send + Sync {
|
||||||
async fn save(&self, user: &User) -> Result<(), DomainError>;
|
async fn save(&self, user: &User) -> Result<(), DomainError>;
|
||||||
async fn update_profile(
|
async fn update_profile(
|
||||||
&self,
|
&self,
|
||||||
@@ -59,10 +65,13 @@ pub trait UserRepository: Send + Sync {
|
|||||||
header_url: Option<String>,
|
header_url: Option<String>,
|
||||||
custom_css: Option<String>,
|
custom_css: Option<String>,
|
||||||
) -> Result<(), DomainError>;
|
) -> Result<(), DomainError>;
|
||||||
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError>;
|
|
||||||
async fn count(&self) -> Result<i64, DomainError>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Combined supertrait — `AppState.users` stays `Arc<dyn UserRepository>`.
|
||||||
|
/// Blanket impl: any type implementing both sub-traits gets `UserRepository` for free.
|
||||||
|
pub trait UserRepository: UserReader + UserWriter {}
|
||||||
|
impl<T: UserReader + UserWriter> UserRepository for T {}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait ThoughtRepository: Send + Sync {
|
pub trait ThoughtRepository: Send + Sync {
|
||||||
async fn save(&self, thought: &Thought) -> Result<(), DomainError>;
|
async fn save(&self, thought: &Thought) -> Result<(), DomainError>;
|
||||||
@@ -221,14 +230,35 @@ pub trait RemoteActorConnectionRepository: Send + Sync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait FederationActionPort: Send + Sync {
|
pub trait FederationLookupPort: Send + Sync {
|
||||||
async fn lookup_actor(&self, handle: &str) -> Result<RemoteActor, DomainError>;
|
async fn lookup_actor(&self, handle: &str) -> Result<RemoteActor, DomainError>;
|
||||||
|
async fn actor_json(&self, user_id: &UserId) -> Result<String, DomainError>;
|
||||||
|
async fn followers_collection_json(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
page: Option<u32>,
|
||||||
|
) -> Result<String, DomainError>;
|
||||||
|
async fn following_collection_json(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
page: Option<u32>,
|
||||||
|
) -> Result<String, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait FederationFollowPort: Send + Sync {
|
||||||
async fn follow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError>;
|
async fn follow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError>;
|
||||||
async fn unfollow_remote(
|
async fn unfollow_remote(
|
||||||
&self,
|
&self,
|
||||||
local_user_id: &UserId,
|
local_user_id: &UserId,
|
||||||
handle: &str,
|
handle: &str,
|
||||||
) -> Result<(), DomainError>;
|
) -> Result<(), DomainError>;
|
||||||
|
async fn get_remote_following(&self, user_id: &UserId)
|
||||||
|
-> Result<Vec<RemoteActor>, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait FederationFollowRequestPort: Send + Sync {
|
||||||
async fn get_pending_followers(
|
async fn get_pending_followers(
|
||||||
&self,
|
&self,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
@@ -250,36 +280,38 @@ pub trait FederationActionPort: Send + Sync {
|
|||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
actor_url: &str,
|
actor_url: &str,
|
||||||
) -> Result<(), DomainError>;
|
) -> Result<(), DomainError>;
|
||||||
async fn get_remote_following(&self, user_id: &UserId)
|
}
|
||||||
-> Result<Vec<RemoteActor>, DomainError>;
|
|
||||||
async fn actor_json(&self, user_id: &UserId) -> Result<String, DomainError>;
|
#[async_trait]
|
||||||
async fn followers_collection_json(
|
pub trait FederationFetchPort: Send + Sync {
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
page: Option<u32>,
|
|
||||||
) -> Result<String, DomainError>;
|
|
||||||
async fn following_collection_json(
|
|
||||||
&self,
|
|
||||||
user_id: &UserId,
|
|
||||||
page: Option<u32>,
|
|
||||||
) -> Result<String, DomainError>;
|
|
||||||
async fn fetch_outbox_page(
|
async fn fetch_outbox_page(
|
||||||
&self,
|
&self,
|
||||||
outbox_url: &str,
|
outbox_url: &str,
|
||||||
page: u32,
|
page: u32,
|
||||||
) -> Result<Vec<crate::models::remote_note::RemoteNote>, DomainError>;
|
) -> Result<Vec<crate::models::remote_note::RemoteNote>, DomainError>;
|
||||||
|
|
||||||
async fn fetch_actor_urls_from_collection(
|
async fn fetch_actor_urls_from_collection(
|
||||||
&self,
|
&self,
|
||||||
collection_url: &str,
|
collection_url: &str,
|
||||||
) -> Result<Vec<String>, DomainError>;
|
) -> Result<Vec<String>, DomainError>;
|
||||||
|
|
||||||
async fn resolve_actor_profiles(
|
async fn resolve_actor_profiles(
|
||||||
&self,
|
&self,
|
||||||
urls: Vec<String>,
|
urls: Vec<String>,
|
||||||
) -> Vec<crate::models::actor_connection_summary::ActorConnectionSummary>;
|
) -> Vec<crate::models::actor_connection_summary::ActorConnectionSummary>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub trait FederationActionPort:
|
||||||
|
FederationLookupPort + FederationFollowPort + FederationFollowRequestPort + FederationFetchPort
|
||||||
|
{
|
||||||
|
}
|
||||||
|
impl<
|
||||||
|
T: FederationLookupPort
|
||||||
|
+ FederationFollowPort
|
||||||
|
+ FederationFollowRequestPort
|
||||||
|
+ FederationFetchPort,
|
||||||
|
> FederationActionPort for T
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait FeedRepository: Send + Sync {
|
pub trait FeedRepository: Send + Sync {
|
||||||
async fn home_feed(
|
async fn home_feed(
|
||||||
@@ -331,6 +363,13 @@ pub trait SearchPort: Send + Sync {
|
|||||||
) -> Result<Paginated<User>, DomainError>;
|
) -> Result<Paginated<User>, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// AP-protocol endpoints for a locally-stored user (local or interned remote).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ActorApUrls {
|
||||||
|
pub ap_id: String,
|
||||||
|
pub inbox_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// A local thought ready for AP serialization, with the author's username
|
/// A local thought ready for AP serialization, with the author's username
|
||||||
/// pre-joined so the handler can build AP URLs without a second query.
|
/// pre-joined so the handler can build AP URLs without a second query.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -362,14 +401,12 @@ pub trait ActivityPubRepository: Send + Sync {
|
|||||||
// ── Remote actor resolution ──────────────────────────────────────
|
// ── Remote actor resolution ──────────────────────────────────────
|
||||||
|
|
||||||
/// Find the local UserId for a remote actor by its AP URL.
|
/// Find the local UserId for a remote actor by its AP URL.
|
||||||
async fn find_remote_actor_id(
|
async fn find_remote_actor_id(&self, actor_ap_url: &str)
|
||||||
&self,
|
-> Result<Option<UserId>, DomainError>;
|
||||||
actor_ap_url: &url::Url,
|
|
||||||
) -> Result<Option<UserId>, DomainError>;
|
|
||||||
|
|
||||||
/// Ensure a remote actor placeholder exists; create one if absent.
|
/// Ensure a remote actor placeholder exists; create one if absent.
|
||||||
/// Idempotent — safe to call multiple times with the same URL.
|
/// Idempotent — safe to call multiple times with the same URL.
|
||||||
async fn intern_remote_actor(&self, actor_ap_url: &url::Url) -> Result<UserId, DomainError>;
|
async fn intern_remote_actor(&self, actor_ap_url: &str) -> Result<UserId, DomainError>;
|
||||||
|
|
||||||
/// Update display_name and avatar_url for an already-interned remote actor.
|
/// Update display_name and avatar_url for an already-interned remote actor.
|
||||||
async fn update_remote_actor_display(
|
async fn update_remote_actor_display(
|
||||||
@@ -385,34 +422,42 @@ pub trait ActivityPubRepository: Send + Sync {
|
|||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn accept_note(
|
async fn accept_note(
|
||||||
&self,
|
&self,
|
||||||
ap_id: &url::Url,
|
ap_id: &str,
|
||||||
author_id: &UserId,
|
author_id: &UserId,
|
||||||
content: &str,
|
content: &str,
|
||||||
published: chrono::DateTime<chrono::Utc>,
|
published: chrono::DateTime<chrono::Utc>,
|
||||||
sensitive: bool,
|
sensitive: bool,
|
||||||
content_warning: Option<String>,
|
content_warning: Option<String>,
|
||||||
visibility: &str,
|
visibility: &str,
|
||||||
in_reply_to: Option<&url::Url>,
|
in_reply_to: Option<&str>,
|
||||||
) -> Result<(), DomainError>;
|
) -> Result<(), DomainError>;
|
||||||
|
|
||||||
/// Apply an Update to a previously accepted remote Note.
|
/// Apply an Update to a previously accepted remote Note.
|
||||||
async fn apply_note_update(
|
async fn apply_note_update(&self, ap_id: &str, new_content: &str) -> Result<(), DomainError>;
|
||||||
&self,
|
|
||||||
ap_id: &url::Url,
|
|
||||||
new_content: &str,
|
|
||||||
) -> Result<(), DomainError>;
|
|
||||||
|
|
||||||
/// Remove a specific remote Note (Delete activity). Only touches
|
/// Remove a specific remote Note (Delete activity). Only touches
|
||||||
/// remotely-originated thoughts.
|
/// remotely-originated thoughts.
|
||||||
async fn retract_note(&self, ap_id: &url::Url) -> Result<(), DomainError>;
|
async fn retract_note(&self, ap_id: &str) -> Result<(), DomainError>;
|
||||||
|
|
||||||
/// Remove all Notes from a remote actor (actor-level Delete/Tombstone).
|
/// Remove all Notes from a remote actor (actor-level Delete/Tombstone).
|
||||||
async fn retract_actor_notes(&self, actor_ap_url: &url::Url) -> Result<(), DomainError>;
|
async fn retract_actor_notes(&self, actor_ap_url: &str) -> Result<(), DomainError>;
|
||||||
|
|
||||||
// ── Node-level stats ─────────────────────────────────────────────
|
// ── Node-level stats ─────────────────────────────────────────────
|
||||||
|
|
||||||
/// Total locally-authored thought count for NodeInfo responses.
|
/// Total locally-authored thought count for NodeInfo responses.
|
||||||
async fn count_local_notes(&self) -> Result<u64, DomainError>;
|
async fn count_local_notes(&self) -> Result<u64, DomainError>;
|
||||||
|
|
||||||
|
/// Return the ActivityPub object URL for a thought, if one is stored.
|
||||||
|
/// Returns None for local thoughts (caller constructs URL from base_url + thought_id).
|
||||||
|
async fn get_thought_ap_id(
|
||||||
|
&self,
|
||||||
|
thought_id: &ThoughtId,
|
||||||
|
) -> Result<Option<String>, DomainError>;
|
||||||
|
|
||||||
|
/// Return the AP actor URL and inbox URL for a user, if stored.
|
||||||
|
/// Returns None for users that have not been federated.
|
||||||
|
async fn get_actor_ap_urls(&self, user_id: &UserId)
|
||||||
|
-> Result<Option<ActorApUrls>, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -423,6 +468,7 @@ pub trait OutboundFederationPort: Send + Sync {
|
|||||||
author_user_id: &UserId,
|
author_user_id: &UserId,
|
||||||
thought: &Thought,
|
thought: &Thought,
|
||||||
author_username: &str,
|
author_username: &str,
|
||||||
|
in_reply_to_url: Option<&str>,
|
||||||
) -> Result<(), DomainError>;
|
) -> Result<(), DomainError>;
|
||||||
|
|
||||||
/// Fan out a Delete tombstone for a now-deleted local Note.
|
/// Fan out a Delete tombstone for a now-deleted local Note.
|
||||||
@@ -440,6 +486,7 @@ pub trait OutboundFederationPort: Send + Sync {
|
|||||||
author_user_id: &UserId,
|
author_user_id: &UserId,
|
||||||
thought: &Thought,
|
thought: &Thought,
|
||||||
author_username: &str,
|
author_username: &str,
|
||||||
|
in_reply_to_url: Option<&str>,
|
||||||
) -> Result<(), DomainError>;
|
) -> Result<(), DomainError>;
|
||||||
|
|
||||||
/// Fan out an Announce(object_ap_id) for a boost.
|
/// Fan out an Announce(object_ap_id) for a boost.
|
||||||
@@ -476,3 +523,20 @@ pub trait OutboundFederationPort: Send + Sync {
|
|||||||
/// Fan out an Update(Actor) to all accepted followers after a profile change.
|
/// Fan out an Update(Actor) to all accepted followers after a profile change.
|
||||||
async fn broadcast_actor_update(&self, user_id: &UserId) -> Result<(), DomainError>;
|
async fn broadcast_actor_update(&self, user_id: &UserId) -> Result<(), DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait FederationSchedulerPort: Send + Sync {
|
||||||
|
async fn schedule_actor_posts_fetch(
|
||||||
|
&self,
|
||||||
|
actor_ap_url: &str,
|
||||||
|
outbox_url: &str,
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
|
|
||||||
|
async fn schedule_connections_fetch(
|
||||||
|
&self,
|
||||||
|
actor_ap_url: &str,
|
||||||
|
collection_url: &str,
|
||||||
|
connection_type: &str,
|
||||||
|
page: u32,
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use url;
|
|
||||||
|
|
||||||
#[derive(Default, Clone)]
|
#[derive(Default, Clone)]
|
||||||
pub struct TestStore {
|
pub struct TestStore {
|
||||||
@@ -35,10 +35,16 @@ pub struct TestStore {
|
|||||||
pub top_friends: Arc<Mutex<Vec<TopFriend>>>,
|
pub top_friends: Arc<Mutex<Vec<TopFriend>>>,
|
||||||
pub notifications: Arc<Mutex<Vec<Notification>>>,
|
pub notifications: Arc<Mutex<Vec<Notification>>>,
|
||||||
pub events: Arc<Mutex<Vec<DomainEvent>>>,
|
pub events: Arc<Mutex<Vec<DomainEvent>>>,
|
||||||
|
/// AP URL → UserId for remote actors (used by find_remote_actor_id / intern_remote_actor)
|
||||||
|
pub actor_ap_ids: Arc<Mutex<HashMap<String, UserId>>>,
|
||||||
|
/// ThoughtId → AP object URL (used by get_thought_ap_id)
|
||||||
|
pub thought_ap_ids: Arc<Mutex<HashMap<ThoughtId, String>>>,
|
||||||
|
/// UserId → ActorApUrls (used by get_actor_ap_urls)
|
||||||
|
pub actor_ap_urls: Arc<Mutex<HashMap<UserId, ActorApUrls>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl UserRepository for TestStore {
|
impl UserReader for TestStore {
|
||||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||||
Ok(self
|
Ok(self
|
||||||
.users
|
.users
|
||||||
@@ -66,6 +72,22 @@ impl UserRepository for TestStore {
|
|||||||
.find(|u| u.email.as_str() == email.as_str())
|
.find(|u| u.email.as_str() == email.as_str())
|
||||||
.cloned())
|
.cloned())
|
||||||
}
|
}
|
||||||
|
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
async fn count(&self) -> Result<i64, DomainError> {
|
||||||
|
Ok(self
|
||||||
|
.users
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter(|u| u.local)
|
||||||
|
.count() as i64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl UserWriter for TestStore {
|
||||||
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
||||||
let mut g = self.users.lock().unwrap();
|
let mut g = self.users.lock().unwrap();
|
||||||
g.retain(|u| u.id != user.id);
|
g.retain(|u| u.id != user.id);
|
||||||
@@ -96,18 +118,6 @@ impl UserRepository for TestStore {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
|
|
||||||
Ok(vec![])
|
|
||||||
}
|
|
||||||
async fn count(&self) -> Result<i64, DomainError> {
|
|
||||||
Ok(self
|
|
||||||
.users
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.iter()
|
|
||||||
.filter(|u| u.local)
|
|
||||||
.count() as i64)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -544,11 +554,34 @@ impl RemoteActorRepository for TestStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl FederationActionPort for TestStore {
|
impl FederationLookupPort for TestStore {
|
||||||
async fn lookup_actor(&self, _handle: &str) -> Result<RemoteActor, DomainError> {
|
async fn lookup_actor(&self, _handle: &str) -> Result<RemoteActor, DomainError> {
|
||||||
Err(DomainError::NotFound)
|
Err(DomainError::NotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn actor_json(&self, _user_id: &UserId) -> Result<String, DomainError> {
|
||||||
|
Err(DomainError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn followers_collection_json(
|
||||||
|
&self,
|
||||||
|
_user_id: &UserId,
|
||||||
|
_page: Option<u32>,
|
||||||
|
) -> Result<String, DomainError> {
|
||||||
|
Err(DomainError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn following_collection_json(
|
||||||
|
&self,
|
||||||
|
_user_id: &UserId,
|
||||||
|
_page: Option<u32>,
|
||||||
|
) -> Result<String, DomainError> {
|
||||||
|
Err(DomainError::NotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FederationFollowPort for TestStore {
|
||||||
async fn follow_remote(
|
async fn follow_remote(
|
||||||
&self,
|
&self,
|
||||||
_local_user_id: &UserId,
|
_local_user_id: &UserId,
|
||||||
@@ -565,6 +598,16 @@ impl FederationActionPort for TestStore {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_remote_following(
|
||||||
|
&self,
|
||||||
|
_user_id: &UserId,
|
||||||
|
) -> Result<Vec<RemoteActor>, DomainError> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FederationFollowRequestPort for TestStore {
|
||||||
async fn get_pending_followers(
|
async fn get_pending_followers(
|
||||||
&self,
|
&self,
|
||||||
_user_id: &UserId,
|
_user_id: &UserId,
|
||||||
@@ -602,34 +645,10 @@ impl FederationActionPort for TestStore {
|
|||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_remote_following(
|
#[async_trait]
|
||||||
&self,
|
impl FederationFetchPort for TestStore {
|
||||||
_user_id: &UserId,
|
|
||||||
) -> Result<Vec<RemoteActor>, DomainError> {
|
|
||||||
Ok(vec![])
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn actor_json(&self, _user_id: &UserId) -> Result<String, DomainError> {
|
|
||||||
Err(DomainError::NotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn followers_collection_json(
|
|
||||||
&self,
|
|
||||||
_user_id: &UserId,
|
|
||||||
_page: Option<u32>,
|
|
||||||
) -> Result<String, DomainError> {
|
|
||||||
Err(DomainError::NotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn following_collection_json(
|
|
||||||
&self,
|
|
||||||
_user_id: &UserId,
|
|
||||||
_page: Option<u32>,
|
|
||||||
) -> Result<String, DomainError> {
|
|
||||||
Err(DomainError::NotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_outbox_page(
|
async fn fetch_outbox_page(
|
||||||
&self,
|
&self,
|
||||||
_outbox_url: &str,
|
_outbox_url: &str,
|
||||||
@@ -800,26 +819,18 @@ impl ActivityPubRepository for TestStore {
|
|||||||
}
|
}
|
||||||
async fn find_remote_actor_id(
|
async fn find_remote_actor_id(
|
||||||
&self,
|
&self,
|
||||||
actor_ap_url: &url::Url,
|
actor_ap_url: &str,
|
||||||
) -> Result<Option<UserId>, DomainError> {
|
) -> Result<Option<UserId>, DomainError> {
|
||||||
let url = actor_ap_url.to_string();
|
Ok(self.actor_ap_ids.lock().unwrap().get(actor_ap_url).cloned())
|
||||||
Ok(self
|
|
||||||
.users
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.iter()
|
|
||||||
.find(|u| u.ap_id.as_deref() == Some(&url))
|
|
||||||
.map(|u| u.id.clone()))
|
|
||||||
}
|
}
|
||||||
async fn intern_remote_actor(&self, actor_ap_url: &url::Url) -> Result<UserId, DomainError> {
|
async fn intern_remote_actor(&self, actor_ap_url: &str) -> Result<UserId, DomainError> {
|
||||||
if let Some(uid) = self.find_remote_actor_id(actor_ap_url).await? {
|
if let Some(uid) = self.find_remote_actor_id(actor_ap_url).await? {
|
||||||
return Ok(uid);
|
return Ok(uid);
|
||||||
}
|
}
|
||||||
let uid = UserId::new();
|
let uid = UserId::new();
|
||||||
let handle = actor_ap_url
|
let handle = url::Url::parse(actor_ap_url)
|
||||||
.path()
|
.map(|u| u.path().trim_start_matches('/').replace('/', "_"))
|
||||||
.trim_start_matches('/')
|
.unwrap_or_else(|_| format!("remote_{}", &uid.to_string()[..8]));
|
||||||
.replace('/', "_");
|
|
||||||
let user = crate::models::user::User {
|
let user = crate::models::user::User {
|
||||||
id: uid.clone(),
|
id: uid.clone(),
|
||||||
username: Username::from_trusted(handle.clone()),
|
username: Username::from_trusted(handle.clone()),
|
||||||
@@ -831,12 +842,14 @@ impl ActivityPubRepository for TestStore {
|
|||||||
header_url: None,
|
header_url: None,
|
||||||
custom_css: None,
|
custom_css: None,
|
||||||
local: false,
|
local: false,
|
||||||
ap_id: Some(actor_ap_url.to_string()),
|
|
||||||
inbox_url: None,
|
|
||||||
created_at: chrono::Utc::now(),
|
created_at: chrono::Utc::now(),
|
||||||
updated_at: chrono::Utc::now(),
|
updated_at: chrono::Utc::now(),
|
||||||
};
|
};
|
||||||
self.users.lock().unwrap().push(user);
|
self.users.lock().unwrap().push(user);
|
||||||
|
self.actor_ap_ids
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(actor_ap_url.to_string(), uid.clone());
|
||||||
Ok(uid)
|
Ok(uid)
|
||||||
}
|
}
|
||||||
async fn update_remote_actor_display(
|
async fn update_remote_actor_display(
|
||||||
@@ -849,28 +862,24 @@ impl ActivityPubRepository for TestStore {
|
|||||||
}
|
}
|
||||||
async fn accept_note(
|
async fn accept_note(
|
||||||
&self,
|
&self,
|
||||||
_ap_id: &url::Url,
|
_ap_id: &str,
|
||||||
_author_id: &UserId,
|
_author_id: &UserId,
|
||||||
_content: &str,
|
_content: &str,
|
||||||
_published: chrono::DateTime<chrono::Utc>,
|
_published: chrono::DateTime<chrono::Utc>,
|
||||||
_sensitive: bool,
|
_sensitive: bool,
|
||||||
_content_warning: Option<String>,
|
_content_warning: Option<String>,
|
||||||
_visibility: &str,
|
_visibility: &str,
|
||||||
_in_reply_to: Option<&url::Url>,
|
_in_reply_to: Option<&str>,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn apply_note_update(
|
async fn apply_note_update(&self, _ap_id: &str, _new_content: &str) -> Result<(), DomainError> {
|
||||||
&self,
|
|
||||||
_ap_id: &url::Url,
|
|
||||||
_new_content: &str,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn retract_note(&self, _ap_id: &url::Url) -> Result<(), DomainError> {
|
async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn retract_actor_notes(&self, _actor_ap_url: &url::Url) -> Result<(), DomainError> {
|
async fn retract_actor_notes(&self, _actor_ap_url: &str) -> Result<(), DomainError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn count_local_notes(&self) -> Result<u64, DomainError> {
|
async fn count_local_notes(&self) -> Result<u64, DomainError> {
|
||||||
@@ -882,6 +891,34 @@ impl ActivityPubRepository for TestStore {
|
|||||||
.filter(|t| t.local)
|
.filter(|t| t.local)
|
||||||
.count() as u64)
|
.count() as u64)
|
||||||
}
|
}
|
||||||
|
async fn get_thought_ap_id(
|
||||||
|
&self,
|
||||||
|
thought_id: &ThoughtId,
|
||||||
|
) -> Result<Option<String>, DomainError> {
|
||||||
|
Ok(self.thought_ap_ids.lock().unwrap().get(thought_id).cloned())
|
||||||
|
}
|
||||||
|
async fn get_actor_ap_urls(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
) -> Result<Option<ActorApUrls>, DomainError> {
|
||||||
|
Ok(self.actor_ap_urls.lock().unwrap().get(user_id).cloned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FederationSchedulerPort for TestStore {
|
||||||
|
async fn schedule_actor_posts_fetch(&self, _: &str, _: &str) -> Result<(), DomainError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn schedule_connections_fetch(
|
||||||
|
&self,
|
||||||
|
_: &str,
|
||||||
|
_: &str,
|
||||||
|
_: &str,
|
||||||
|
_: u32,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -918,9 +955,9 @@ mod ap_repo_tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_store_intern_creates_placeholder() {
|
async fn test_store_intern_creates_placeholder() {
|
||||||
let store = TestStore::default();
|
let store = TestStore::default();
|
||||||
let url = url::Url::parse("https://example.com/users/alice").unwrap();
|
let url = "https://example.com/users/alice";
|
||||||
let id1 = store.intern_remote_actor(&url).await.unwrap();
|
let id1 = store.intern_remote_actor(url).await.unwrap();
|
||||||
let id2 = store.intern_remote_actor(&url).await.unwrap();
|
let id2 = store.intern_remote_actor(url).await.unwrap();
|
||||||
assert_eq!(id1, id2, "intern must be idempotent");
|
assert_eq!(id1, id2, "intern must be idempotent");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ pub async fn remote_actor_posts_handler(
|
|||||||
&*s.federation,
|
&*s.federation,
|
||||||
&*s.ap_repo,
|
&*s.ap_repo,
|
||||||
&*s.feed,
|
&*s.feed,
|
||||||
&*s.events,
|
&*s.federation_scheduler,
|
||||||
&handle,
|
&handle,
|
||||||
page,
|
page,
|
||||||
viewer.as_ref(),
|
viewer.as_ref(),
|
||||||
@@ -68,7 +68,7 @@ async fn actor_connections_handler(
|
|||||||
let (items, has_more) = get_actor_connections_page(
|
let (items, has_more) = get_actor_connections_page(
|
||||||
&*s.federation,
|
&*s.federation,
|
||||||
&*s.remote_actor_connections,
|
&*s.remote_actor_connections,
|
||||||
&*s.events,
|
&*s.federation_scheduler,
|
||||||
&handle,
|
&handle,
|
||||||
connection_type,
|
connection_type,
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use application::use_cases::feed::{
|
|||||||
get_popular_tags as uc_get_popular_tags, get_public_feed, get_user_feed,
|
get_popular_tags as uc_get_popular_tags, get_public_feed, get_user_feed,
|
||||||
};
|
};
|
||||||
use application::use_cases::profile::{get_user_by_id_or_username, get_user_by_username};
|
use application::use_cases::profile::{get_user_by_id_or_username, get_user_by_username};
|
||||||
use application::use_cases::search::{search_thoughts, search_users};
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::{header, HeaderMap},
|
http::{header, HeaderMap},
|
||||||
@@ -26,15 +25,15 @@ pub fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtRespon
|
|||||||
content: e.thought.content.as_str().to_string(),
|
content: e.thought.content.as_str().to_string(),
|
||||||
author: to_user_response(&e.author),
|
author: to_user_response(&e.author),
|
||||||
in_reply_to_id: e.thought.in_reply_to_id.as_ref().map(|id| id.as_uuid()),
|
in_reply_to_id: e.thought.in_reply_to_id.as_ref().map(|id| id.as_uuid()),
|
||||||
in_reply_to_url: e.thought.in_reply_to_url.clone(),
|
in_reply_to_url: None,
|
||||||
visibility: e.thought.visibility.as_str().to_string(),
|
visibility: e.thought.visibility.as_str().to_string(),
|
||||||
content_warning: e.thought.content_warning.clone(),
|
content_warning: e.thought.content_warning.clone(),
|
||||||
sensitive: e.thought.sensitive,
|
sensitive: e.thought.sensitive,
|
||||||
like_count: e.like_count,
|
like_count: e.stats.like_count,
|
||||||
boost_count: e.boost_count,
|
boost_count: e.stats.boost_count,
|
||||||
reply_count: e.reply_count,
|
reply_count: e.stats.reply_count,
|
||||||
liked_by_viewer: e.liked_by_viewer,
|
liked_by_viewer: e.viewer.as_ref().map(|v| v.liked).unwrap_or(false),
|
||||||
boosted_by_viewer: e.boosted_by_viewer,
|
boosted_by_viewer: e.viewer.as_ref().map(|v| v.boosted).unwrap_or(false),
|
||||||
created_at: e.thought.created_at,
|
created_at: e.thought.created_at,
|
||||||
updated_at: e.thought.updated_at,
|
updated_at: e.thought.updated_at,
|
||||||
}
|
}
|
||||||
@@ -104,39 +103,14 @@ pub async fn search_handler(
|
|||||||
let query = q.q.trim().to_string();
|
let query = q.q.trim().to_string();
|
||||||
|
|
||||||
let (thoughts_result, users_result) = tokio::join!(
|
let (thoughts_result, users_result) = tokio::join!(
|
||||||
search_thoughts(
|
s.search.search_thoughts(&query, &page, viewer.as_ref()),
|
||||||
&*s.search,
|
s.search.search_users(&query, &page),
|
||||||
&query,
|
|
||||||
PageParams {
|
|
||||||
page: page.page,
|
|
||||||
per_page: page.per_page
|
|
||||||
},
|
|
||||||
viewer.as_ref()
|
|
||||||
),
|
|
||||||
search_users(
|
|
||||||
&*s.search,
|
|
||||||
&query,
|
|
||||||
PageParams {
|
|
||||||
page: page.page,
|
|
||||||
per_page: page.per_page
|
|
||||||
}
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let thoughts = thoughts_result?
|
let thoughts = thoughts_result?
|
||||||
.items
|
.items
|
||||||
.into_iter()
|
.iter()
|
||||||
.map(|e| {
|
.map(to_thought_response)
|
||||||
serde_json::json!({
|
|
||||||
"id": e.thought.id.as_uuid(),
|
|
||||||
"content": e.thought.content.as_str(),
|
|
||||||
"author": to_user_response(&e.author),
|
|
||||||
"like_count": e.like_count,
|
|
||||||
"boost_count": e.boost_count,
|
|
||||||
"reply_count": e.reply_count,
|
|
||||||
"created_at": e.thought.created_at,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let users = users_result?
|
let users = users_result?
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ use application::use_cases::feed::list_users_paginated;
|
|||||||
use application::use_cases::profile::{
|
use application::use_cases::profile::{
|
||||||
get_user as fetch_user, get_user_by_id_or_username, update_profile,
|
get_user as fetch_user, get_user_by_id_or_username, update_profile,
|
||||||
};
|
};
|
||||||
use application::use_cases::search::search_users;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::{header, HeaderMap},
|
http::{header, HeaderMap},
|
||||||
@@ -135,7 +134,7 @@ pub async fn get_users(
|
|||||||
let page_params = PageParams { page, per_page };
|
let page_params = PageParams { page, per_page };
|
||||||
|
|
||||||
if let Some(q) = params.get("q").filter(|q| !q.trim().is_empty()) {
|
if let Some(q) = params.get("q").filter(|q| !q.trim().is_empty()) {
|
||||||
let result = search_users(&*s.search, q, page_params).await?;
|
let result = s.search.search_users(q, &page_params).await?;
|
||||||
let users: Vec<_> = result
|
let users: Vec<_> = result
|
||||||
.items
|
.items
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -22,4 +22,5 @@ pub struct AppState {
|
|||||||
pub federation: Arc<dyn FederationActionPort>,
|
pub federation: Arc<dyn FederationActionPort>,
|
||||||
pub ap_repo: Arc<dyn ActivityPubRepository>,
|
pub ap_repo: Arc<dyn ActivityPubRepository>,
|
||||||
pub remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>,
|
pub remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>,
|
||||||
|
pub federation_scheduler: Arc<dyn FederationSchedulerPort>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,5 +51,6 @@ pub fn make_state() -> AppState {
|
|||||||
federation: store.clone(),
|
federation: store.clone(),
|
||||||
ap_repo: store.clone(),
|
ap_repo: store.clone(),
|
||||||
remote_actor_connections: store.clone(),
|
remote_actor_connections: store.clone(),
|
||||||
|
federation_scheduler: store.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ use std::sync::Arc;
|
|||||||
use activitypub::ThoughtsObjectHandler;
|
use activitypub::ThoughtsObjectHandler;
|
||||||
use activitypub_base::ActivityPubService;
|
use activitypub_base::ActivityPubService;
|
||||||
use application::services::{FederationEventService, NotificationEventService};
|
use application::services::{FederationEventService, NotificationEventService};
|
||||||
use domain::ports::{ActivityPubRepository, FederationActionPort, OutboundFederationPort};
|
use domain::ports::{ActivityPubRepository, OutboundFederationPort};
|
||||||
use postgres::activitypub::PgActivityPubRepository;
|
use postgres::activitypub::PgActivityPubRepository;
|
||||||
use postgres::remote_actor_connections::PgRemoteActorConnectionRepository;
|
|
||||||
use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository};
|
use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository};
|
||||||
|
|
||||||
use crate::handlers::{FederationHandler, NotificationHandler};
|
use crate::handlers::{FederationHandler, NotificationHandler};
|
||||||
@@ -58,11 +57,8 @@ pub async fn build(
|
|||||||
.expect("ActivityPubService build failed"),
|
.expect("ActivityPubService build failed"),
|
||||||
);
|
);
|
||||||
let ap_outbound = ap_service.clone() as Arc<dyn OutboundFederationPort>;
|
let ap_outbound = ap_service.clone() as Arc<dyn OutboundFederationPort>;
|
||||||
let ap_federation = ap_service.clone() as Arc<dyn FederationActionPort>;
|
|
||||||
let ap_repo_worker =
|
let ap_repo_worker =
|
||||||
Arc::new(PgActivityPubRepository::new(pool.clone())) as Arc<dyn ActivityPubRepository>;
|
Arc::new(PgActivityPubRepository::new(pool.clone())) as Arc<dyn ActivityPubRepository>;
|
||||||
let actor_connections = Arc::new(PgRemoteActorConnectionRepository::new(pool.clone()))
|
|
||||||
as Arc<dyn domain::ports::RemoteActorConnectionRepository>;
|
|
||||||
|
|
||||||
// Application services
|
// Application services
|
||||||
let notification_svc = Arc::new(NotificationEventService {
|
let notification_svc = Arc::new(NotificationEventService {
|
||||||
@@ -74,9 +70,7 @@ pub async fn build(
|
|||||||
users,
|
users,
|
||||||
ap: ap_outbound,
|
ap: ap_outbound,
|
||||||
base_url: base_url.to_string(),
|
base_url: base_url.to_string(),
|
||||||
federation_action: ap_federation,
|
|
||||||
ap_repo: ap_repo_worker,
|
ap_repo: ap_repo_worker,
|
||||||
remote_actor_connections: actor_connections,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Thin handlers
|
// Thin handlers
|
||||||
|
|||||||
1230
docs/superpowers/plans/2026-05-15-domain-application-refactor.md
Normal file
1230
docs/superpowers/plans/2026-05-15-domain-application-refactor.md
Normal file
File diff suppressed because it is too large
Load Diff
492
docs/superpowers/plans/2026-05-15-feedentry-decoupling.md
Normal file
492
docs/superpowers/plans/2026-05-15-feedentry-decoupling.md
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
# FeedEntry Decoupling Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace the flat `liked_by_viewer`/`boosted_by_viewer` booleans and inline stats fields on `FeedEntry` with two named sub-structs (`EngagementStats`, `Option<ViewerContext>`), and fix the search adapter to compute real viewer context instead of hardcoding `false`.
|
||||||
|
|
||||||
|
**Architecture:** Three sequential tasks. Task 1 changes the domain model, which breaks compilation. Task 2 fixes all downstream construction sites and restores compilation. Task 3 adds the functional improvement — viewer-aware SQL in the search adapter.
|
||||||
|
|
||||||
|
**Tech Stack:** Rust, SQLx, Postgres trigram search (`pg_trgm`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add `EngagementStats` and `ViewerContext` to the domain model
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/domain/src/models/feed.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the flat fields on `FeedEntry` with two named sub-structs**
|
||||||
|
|
||||||
|
Replace the entire contents of `crates/domain/src/models/feed.rs` with:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use crate::models::{thought::Thought, user::User};
|
||||||
|
use crate::value_objects::UserId;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EngagementStats {
|
||||||
|
pub like_count: i64,
|
||||||
|
pub boost_count: i64,
|
||||||
|
pub reply_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Present only when an authenticated viewer made the request.
|
||||||
|
/// `liked`/`boosted` are the viewer's interaction state with this thought.
|
||||||
|
/// `None` means anonymous request or viewer context unavailable.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ViewerContext {
|
||||||
|
pub liked: bool,
|
||||||
|
pub boosted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FeedEntry {
|
||||||
|
pub thought: Thought,
|
||||||
|
pub author: User,
|
||||||
|
pub stats: EngagementStats,
|
||||||
|
pub viewer: Option<ViewerContext>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct UserSummary {
|
||||||
|
pub id: UserId,
|
||||||
|
pub username: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub thought_count: i64,
|
||||||
|
pub follower_count: i64,
|
||||||
|
pub following_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PageParams {
|
||||||
|
pub page: u64,
|
||||||
|
pub per_page: u64,
|
||||||
|
}
|
||||||
|
impl PageParams {
|
||||||
|
pub fn offset(&self) -> i64 {
|
||||||
|
((self.page.saturating_sub(1)) * self.per_page) as i64
|
||||||
|
}
|
||||||
|
pub fn limit(&self) -> i64 {
|
||||||
|
self.per_page as i64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Paginated<T> {
|
||||||
|
pub items: Vec<T>,
|
||||||
|
pub total: i64,
|
||||||
|
pub page: u64,
|
||||||
|
pub per_page: u64,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the domain crate compiles (other crates will break)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo check -p domain 2>&1 | head -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `domain` compiles clean. Other crates (`postgres`, `postgres-search`, `presentation`) will show errors referencing the removed fields — that is expected and will be fixed in Task 2.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit the domain model change**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/domain/src/models/feed.rs
|
||||||
|
git commit -m "refactor(domain): FeedEntry — EngagementStats + Option<ViewerContext> sub-structs"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Fix downstream compilation — adapters and handler
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/adapters/postgres/src/feed.rs` (line 136 — `row_to_entry`)
|
||||||
|
- Modify: `crates/adapters/postgres-search/src/lib.rs` (line 97 — `row_to_entry`)
|
||||||
|
- Modify: `crates/presentation/src/handlers/feed.rs` (line 22 — `to_thought_response`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update `row_to_entry` in `postgres/src/feed.rs`**
|
||||||
|
|
||||||
|
Find `row_to_entry` in `crates/adapters/postgres/src/feed.rs` (around line 109). Replace the `Ok(FeedEntry { ... })` block (currently lines 136–144) with:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
Ok(FeedEntry {
|
||||||
|
thought,
|
||||||
|
author,
|
||||||
|
stats: domain::models::feed::EngagementStats {
|
||||||
|
like_count: r.like_count,
|
||||||
|
boost_count: r.boost_count,
|
||||||
|
reply_count: r.reply_count,
|
||||||
|
},
|
||||||
|
viewer: Some(domain::models::feed::ViewerContext {
|
||||||
|
liked: r.liked_by_viewer,
|
||||||
|
boosted: r.boosted_by_viewer,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `postgres/src/feed.rs` already builds `viewer = Some(...)` unconditionally here because its `feed_select(viewer)` function always produces `liked_by_viewer`/`boosted_by_viewer` columns — `false AS liked_by_viewer` when there is no viewer, and the real EXISTS result when there is one. The `Option<ViewerContext>` distinction (`None` = anonymous) is handled by the caller's knowledge of whether a viewer was passed. To preserve the `None`-when-no-viewer semantic, read how `viewer` is passed into the calling functions and thread it through.
|
||||||
|
|
||||||
|
Actually, the correct fix: the `row_to_entry` function doesn't know if a viewer was passed. Pass the viewer `Option<uuid::Uuid>` as a parameter so it can decide:
|
||||||
|
|
||||||
|
Replace the signature of `row_to_entry`:
|
||||||
|
```rust
|
||||||
|
fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, DomainError> {
|
||||||
|
```
|
||||||
|
|
||||||
|
And change the construction:
|
||||||
|
```rust
|
||||||
|
Ok(FeedEntry {
|
||||||
|
thought,
|
||||||
|
author,
|
||||||
|
stats: domain::models::feed::EngagementStats {
|
||||||
|
like_count: r.like_count,
|
||||||
|
boost_count: r.boost_count,
|
||||||
|
reply_count: r.reply_count,
|
||||||
|
},
|
||||||
|
viewer: viewer.map(|_| domain::models::feed::ViewerContext {
|
||||||
|
liked: r.liked_by_viewer,
|
||||||
|
boosted: r.boosted_by_viewer,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Then update all call sites of `row_to_entry` inside `postgres/src/feed.rs`. Each `FeedRepository` method already has a `viewer` variable of type `Option<uuid::Uuid>`. Pass it through:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Before:
|
||||||
|
.map(row_to_entry)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?
|
||||||
|
|
||||||
|
// After:
|
||||||
|
.map(|r| row_to_entry(r, viewer))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?
|
||||||
|
```
|
||||||
|
|
||||||
|
Read `crates/adapters/postgres/src/feed.rs` to find all five `impl FeedRepository` methods and update each `.map(row_to_entry)` call.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `row_to_entry` in `postgres-search/src/lib.rs`**
|
||||||
|
|
||||||
|
In `crates/adapters/postgres-search/src/lib.rs`, find `row_to_entry` (line 70). Change the `Ok(FeedEntry { ... })` block (lines 97–105) to:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
Ok(FeedEntry {
|
||||||
|
thought,
|
||||||
|
author,
|
||||||
|
stats: domain::models::feed::EngagementStats {
|
||||||
|
like_count: r.like_count,
|
||||||
|
boost_count: r.boost_count,
|
||||||
|
reply_count: r.reply_count,
|
||||||
|
},
|
||||||
|
viewer: None, // Task 3 will fix this to use real viewer data
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `EngagementStats` and `ViewerContext` to the domain import at the top if needed (they're in `domain::models::feed`). The existing import already pulls in `FeedEntry` from that module.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `to_thought_response` in `presentation/src/handlers/feed.rs`**
|
||||||
|
|
||||||
|
Find `to_thought_response` (line 22 in `crates/presentation/src/handlers/feed.rs`). Update it to read from the new sub-structs:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse {
|
||||||
|
ThoughtResponse {
|
||||||
|
id: e.thought.id.as_uuid(),
|
||||||
|
content: e.thought.content.as_str().to_string(),
|
||||||
|
author: to_user_response(&e.author),
|
||||||
|
in_reply_to_id: e.thought.in_reply_to_id.as_ref().map(|id| id.as_uuid()),
|
||||||
|
in_reply_to_url: None,
|
||||||
|
visibility: e.thought.visibility.as_str().to_string(),
|
||||||
|
content_warning: e.thought.content_warning.clone(),
|
||||||
|
sensitive: e.thought.sensitive,
|
||||||
|
like_count: e.stats.like_count,
|
||||||
|
boost_count: e.stats.boost_count,
|
||||||
|
reply_count: e.stats.reply_count,
|
||||||
|
liked_by_viewer: e.viewer.as_ref().map(|v| v.liked).unwrap_or(false),
|
||||||
|
boosted_by_viewer: e.viewer.as_ref().map(|v| v.boosted).unwrap_or(false),
|
||||||
|
created_at: e.thought.created_at,
|
||||||
|
updated_at: e.thought.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ThoughtResponse` in `api-types/src/responses.rs` keeps `liked_by_viewer: bool` and `boosted_by_viewer: bool` — the wire format is unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Compile check — full workspace must be clean**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo check --workspace 2>&1 | head -20
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 0 errors. Fix any remaining references to the old flat fields (`e.like_count`, `e.liked_by_viewer`, etc.) — they must become `e.stats.like_count`, `e.viewer.as_ref().map(|v| v.liked).unwrap_or(false)`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/adapters/postgres/src/feed.rs \
|
||||||
|
crates/adapters/postgres-search/src/lib.rs \
|
||||||
|
crates/presentation/src/handlers/feed.rs
|
||||||
|
git commit -m "refactor(adapters): update FeedEntry construction to use EngagementStats + ViewerContext"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Fix search adapter — real viewer context instead of hardcoded `false`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/adapters/postgres-search/src/lib.rs`
|
||||||
|
|
||||||
|
The `SearchPort::search_thoughts` signature already takes `viewer_id: Option<&UserId>` (the parameter is named `_viewer_id` because it was ignored). This task makes it real.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `liked_by_viewer` and `boosted_by_viewer` to `FeedRow`**
|
||||||
|
|
||||||
|
In `crates/adapters/postgres-search/src/lib.rs`, find the `FeedRow` struct (line 27). Add two fields at the end:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct FeedRow {
|
||||||
|
thought_id: uuid::Uuid,
|
||||||
|
t_user_id: uuid::Uuid,
|
||||||
|
content: String,
|
||||||
|
in_reply_to_id: Option<uuid::Uuid>,
|
||||||
|
visibility: String,
|
||||||
|
content_warning: Option<String>,
|
||||||
|
sensitive: bool,
|
||||||
|
t_local: bool,
|
||||||
|
thought_created_at: DateTime<Utc>,
|
||||||
|
updated_at: Option<DateTime<Utc>>,
|
||||||
|
author_id: uuid::Uuid,
|
||||||
|
username: String,
|
||||||
|
email: String,
|
||||||
|
password_hash: String,
|
||||||
|
display_name: Option<String>,
|
||||||
|
bio: Option<String>,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
header_url: Option<String>,
|
||||||
|
custom_css: Option<String>,
|
||||||
|
author_local: bool,
|
||||||
|
author_created_at: DateTime<Utc>,
|
||||||
|
author_updated_at: DateTime<Utc>,
|
||||||
|
like_count: i64,
|
||||||
|
boost_count: i64,
|
||||||
|
reply_count: i64,
|
||||||
|
liked_by_viewer: bool, // NEW
|
||||||
|
boosted_by_viewer: bool, // NEW
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace `FEED_SELECT` constant with a `feed_select(viewer)` function**
|
||||||
|
|
||||||
|
Delete the `const FEED_SELECT` and replace with a function that injects viewer-aware columns — identical pattern to `postgres/src/feed.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn feed_select(viewer: Option<uuid::Uuid>) -> String {
|
||||||
|
let viewer_checks = match viewer {
|
||||||
|
Some(uid) => format!(
|
||||||
|
"EXISTS(SELECT 1 FROM likes WHERE user_id='{uid}' AND thought_id=t.id) AS liked_by_viewer,
|
||||||
|
EXISTS(SELECT 1 FROM boosts WHERE user_id='{uid}' AND thought_id=t.id) AS boosted_by_viewer"
|
||||||
|
),
|
||||||
|
None => "false AS liked_by_viewer, false AS boosted_by_viewer".to_string(),
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"
|
||||||
|
SELECT
|
||||||
|
t.id AS thought_id, t.user_id AS t_user_id, t.content,
|
||||||
|
t.in_reply_to_id,
|
||||||
|
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,
|
||||||
|
t.created_at AS thought_created_at, t.updated_at,
|
||||||
|
u.id AS author_id, u.username, u.email, u.password_hash,
|
||||||
|
u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css,
|
||||||
|
u.local AS author_local,
|
||||||
|
u.created_at AS author_created_at, u.updated_at AS author_updated_at,
|
||||||
|
(SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,
|
||||||
|
(SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,
|
||||||
|
(SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count,
|
||||||
|
{viewer_checks}
|
||||||
|
FROM thoughts t JOIN users u ON u.id=t.user_id"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update `row_to_entry` to use viewer fields**
|
||||||
|
|
||||||
|
Update `row_to_entry` to accept `viewer: Option<uuid::Uuid>` and build the `ViewerContext`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, DomainError> {
|
||||||
|
let thought = Thought {
|
||||||
|
id: ThoughtId::from_uuid(r.thought_id),
|
||||||
|
user_id: UserId::from_uuid(r.t_user_id),
|
||||||
|
content: Content::new_remote(r.content),
|
||||||
|
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
|
||||||
|
visibility: Visibility::from_db_str(&r.visibility)?,
|
||||||
|
content_warning: r.content_warning,
|
||||||
|
sensitive: r.sensitive,
|
||||||
|
local: r.t_local,
|
||||||
|
created_at: r.thought_created_at,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
};
|
||||||
|
let author = User {
|
||||||
|
id: UserId::from_uuid(r.author_id),
|
||||||
|
username: Username::from_trusted(r.username),
|
||||||
|
email: Email::from_trusted(r.email),
|
||||||
|
password_hash: PasswordHash(r.password_hash),
|
||||||
|
display_name: r.display_name,
|
||||||
|
bio: r.bio,
|
||||||
|
avatar_url: r.avatar_url,
|
||||||
|
header_url: r.header_url,
|
||||||
|
custom_css: r.custom_css,
|
||||||
|
local: r.author_local,
|
||||||
|
created_at: r.author_created_at,
|
||||||
|
updated_at: r.author_updated_at,
|
||||||
|
};
|
||||||
|
Ok(FeedEntry {
|
||||||
|
thought,
|
||||||
|
author,
|
||||||
|
stats: domain::models::feed::EngagementStats {
|
||||||
|
like_count: r.like_count,
|
||||||
|
boost_count: r.boost_count,
|
||||||
|
reply_count: r.reply_count,
|
||||||
|
},
|
||||||
|
viewer: viewer.map(|_| domain::models::feed::ViewerContext {
|
||||||
|
liked: r.liked_by_viewer,
|
||||||
|
boosted: r.boosted_by_viewer,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update `search_thoughts` to use viewer_id**
|
||||||
|
|
||||||
|
Find `search_thoughts` in `crates/adapters/postgres-search/src/lib.rs` (line 110). Rename `_viewer_id` → `viewer_id`, extract the viewer UUID, and thread it through `feed_select` and `row_to_entry`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn search_thoughts(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
page: &PageParams,
|
||||||
|
viewer_id: Option<&UserId>, // was _viewer_id
|
||||||
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
let viewer = viewer_id.map(|v| v.as_uuid());
|
||||||
|
let select = feed_select(viewer);
|
||||||
|
|
||||||
|
let total: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM thoughts t
|
||||||
|
WHERE t.content % $1 AND t.visibility='public'",
|
||||||
|
)
|
||||||
|
.bind(query)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let sql = format!(
|
||||||
|
"{select}
|
||||||
|
WHERE t.content % $1 AND t.visibility='public'
|
||||||
|
ORDER BY similarity(t.content, $1) DESC
|
||||||
|
LIMIT $2 OFFSET $3"
|
||||||
|
);
|
||||||
|
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||||
|
.bind(query)
|
||||||
|
.bind(page.limit())
|
||||||
|
.bind(page.offset())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Paginated {
|
||||||
|
items: rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| row_to_entry(r, viewer))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
|
total,
|
||||||
|
page: page.page,
|
||||||
|
per_page: page.per_page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `USER_SELECT` from `postgres::user` is no longer used in this file after the switch from const to function. Remove the `use postgres::user::{UserRow, USER_SELECT};` import if `UserRow`/`USER_SELECT` are no longer referenced.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add an integration test for viewer-aware search**
|
||||||
|
|
||||||
|
In the `#[cfg(test)]` module in `postgres-search/src/lib.rs`, add after the existing tests:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[sqlx::test(migrations = "../postgres/migrations")]
|
||||||
|
async fn search_thoughts_sets_viewer_context_when_authed(pool: sqlx::PgPool) {
|
||||||
|
use domain::ports::{LikeRepository, UserWriter};
|
||||||
|
use postgres::{like::PgLikeRepository, user::PgUserRepository};
|
||||||
|
use domain::models::social::Like;
|
||||||
|
use domain::value_objects::LikeId;
|
||||||
|
|
||||||
|
let (alice, thought) = seed_thought(&pool, "alice", "hello world").await;
|
||||||
|
|
||||||
|
// alice likes her own thought
|
||||||
|
let like_repo = PgLikeRepository::new(pool.clone());
|
||||||
|
like_repo.save(&Like {
|
||||||
|
id: LikeId::new(),
|
||||||
|
user_id: alice.id.clone(),
|
||||||
|
thought_id: thought.id.clone(),
|
||||||
|
ap_id: None,
|
||||||
|
created_at: chrono::Utc::now(),
|
||||||
|
}).await.unwrap();
|
||||||
|
|
||||||
|
let repo = PgSearchRepository::new(pool);
|
||||||
|
|
||||||
|
// with viewer — should see liked = true
|
||||||
|
let authed = repo
|
||||||
|
.search_thoughts("hello", &PageParams { page: 1, per_page: 20 }, Some(&alice.id))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(authed.items.len(), 1);
|
||||||
|
let ctx = authed.items[0].viewer.as_ref().expect("viewer context present");
|
||||||
|
assert!(ctx.liked, "alice should see the thought as liked");
|
||||||
|
assert!(!ctx.boosted);
|
||||||
|
|
||||||
|
// without viewer — viewer should be None
|
||||||
|
let anon = repo
|
||||||
|
.search_thoughts("hello", &PageParams { page: 1, per_page: 20 }, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(anon.items.len(), 1);
|
||||||
|
assert!(anon.items[0].viewer.is_none(), "anonymous request has no viewer context");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Compile check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo check --workspace 2>&1 | head -20
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/adapters/postgres-search/src/lib.rs
|
||||||
|
git commit -m "fix(search): viewer-aware SQL in search_thoughts — ViewerContext now real instead of hardcoded false"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage:**
|
||||||
|
|
||||||
|
| Spec requirement | Task |
|
||||||
|
|---|---|
|
||||||
|
| Add `EngagementStats` struct | Task 1 |
|
||||||
|
| Add `ViewerContext` struct | Task 1 |
|
||||||
|
| `FeedEntry.viewer: Option<ViewerContext>` | Task 1 |
|
||||||
|
| postgres feed adapter uses new structs | Task 2 |
|
||||||
|
| Handler `to_thought_response` uses new fields | Task 2 |
|
||||||
|
| search adapter `viewer: None` (structural fix) | Task 2 |
|
||||||
|
| search adapter uses real viewer SQL (functional fix) | Task 3 |
|
||||||
|
| `viewer: None` = anonymous; `Some(...)` = viewer present | Tasks 2 + 3 |
|
||||||
|
| Wire format (`ThoughtResponse`) unchanged | Task 2 step 3 |
|
||||||
|
|
||||||
|
**No placeholders found.**
|
||||||
|
|
||||||
|
**Type consistency:** `EngagementStats` and `ViewerContext` defined in Task 1, used by name in Tasks 2 and 3. `row_to_entry(r, viewer)` signature matches in both Task 2 and Task 3. `viewer: Option<uuid::Uuid>` threaded consistently.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# FeedEntry Decoupling Design
|
||||||
|
|
||||||
|
**Goal:** Fix search viewer context (functional), restructure `FeedEntry` for clarity (structural), and make viewer presence explicit via `Option<ViewerContext>` (type-safe).
|
||||||
|
|
||||||
|
**Priority:** C (search fix) → B (struct clarity) → A (type safety). All three land in one pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
Replace flat fields on `FeedEntry` with two named sub-structs in `crates/domain/src/models/feed.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EngagementStats {
|
||||||
|
pub like_count: i64,
|
||||||
|
pub boost_count: i64,
|
||||||
|
pub reply_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ViewerContext {
|
||||||
|
pub liked: bool,
|
||||||
|
pub boosted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FeedEntry {
|
||||||
|
pub thought: Thought,
|
||||||
|
pub author: User,
|
||||||
|
pub stats: EngagementStats,
|
||||||
|
pub viewer: Option<ViewerContext>, // None when no authenticated viewer
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`viewer: None` means the request was anonymous or viewer state is unavailable (e.g. search without auth). `viewer: Some(ViewerContext { liked: false, boosted: false })` means a viewer is known and they have not liked or boosted the thought. These two states are now distinct at the type level.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Search Adapter Fix
|
||||||
|
|
||||||
|
`SearchPort::search_thoughts` already accepts `viewer_id: Option<&UserId>` but `postgres-search/src/lib.rs` ignores it, always hardcoding `false` for viewer fields.
|
||||||
|
|
||||||
|
Fix: conditionally inject EXISTS subqueries into the search SQL, identical to the pattern used in `postgres/src/feed.rs`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- viewer_id = None (anonymous)
|
||||||
|
false AS liked_by_viewer,
|
||||||
|
false AS boosted_by_viewer
|
||||||
|
|
||||||
|
-- viewer_id = Some(uid)
|
||||||
|
EXISTS(SELECT 1 FROM likes WHERE user_id='{uid}' AND thought_id=t.id) AS liked_by_viewer,
|
||||||
|
EXISTS(SELECT 1 FROM boosts WHERE user_id='{uid}' AND thought_id=t.id) AS boosted_by_viewer
|
||||||
|
```
|
||||||
|
|
||||||
|
The `FeedRow` struct in postgres-search already has `liked_by_viewer: bool` and `boosted_by_viewer: bool` columns — they just need to be populated correctly. No schema change required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Callsite Migration
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `crates/domain/src/models/feed.rs` | Replace flat stats/viewer fields with `EngagementStats` and `Option<ViewerContext>` |
|
||||||
|
| `crates/adapters/postgres/src/feed.rs` — `row_to_entry` | Construct `EngagementStats { ... }` and `viewer: Some/None` based on `FeedRow` |
|
||||||
|
| `crates/adapters/postgres-search/src/lib.rs` — `row_to_entry` + SQL | Fix SQL to use viewer_id; build `Option<ViewerContext>` from result |
|
||||||
|
| `crates/presentation/src/handlers/feed.rs` — `to_thought_response` | `e.stats.like_count`, `e.viewer.as_ref().map(|v| v.liked).unwrap_or(false)` |
|
||||||
|
| `crates/domain/src/testing.rs` — `TestStore` feed impl | Build `FeedEntry` with `stats:` and `viewer:` fields |
|
||||||
|
|
||||||
|
`ThoughtResponse` in `api-types/src/responses.rs` keeps `liked_by_viewer: bool` and `boosted_by_viewer: bool` — the wire format is unchanged. `viewer: None` serialises as `false` in `to_thought_response`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Does Not Change
|
||||||
|
|
||||||
|
- `FeedRepository` port signatures (still returns `Paginated<FeedEntry>`)
|
||||||
|
- HTTP response shape (`ThoughtResponse`)
|
||||||
|
- Database schema
|
||||||
|
- Pagination, filtering, or query logic
|
||||||
|
- Any code path that doesn't touch `FeedEntry` fields directly
|
||||||
Reference in New Issue
Block a user