feat: implement merge readiness plan to close gaps between v2 and v1
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 5m8s
test / unit (pull_request) Successful in 16m18s
test / integration (pull_request) Failing after 16m59s
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 5m8s
test / unit (pull_request) Successful in 16m18s
test / integration (pull_request) Failing after 16m59s
- Task 1: Fix feed response hydration by adding `to_thought_response` helper and updating feed handlers to return full `ThoughtResponse`. - Task 2: Wire follower/following REST routes for user feeds. - Task 3: Add user listing and count endpoints, including `GET /users` and `GET /users/count`. - Task 4: Implement popular tags feature with `GET /tags/popular`. - Task 5: Enhance configuration with HOST, CORS_ORIGINS, and optional rate limiting using tower-governor.
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
use std::sync::Arc;
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::sync::Arc;
|
||||
use url::Url;
|
||||
|
||||
use crate::note::ThoughtNote;
|
||||
use crate::urls::ThoughtsUrls;
|
||||
use activitypub_base::ApObjectHandler;
|
||||
use domain::ports::ActivityPubRepository;
|
||||
use domain::value_objects::UserId;
|
||||
use crate::note::ThoughtNote;
|
||||
use crate::urls::ThoughtsUrls;
|
||||
|
||||
pub struct ThoughtsObjectHandler {
|
||||
repo: Arc<dyn ActivityPubRepository>,
|
||||
@@ -17,7 +17,10 @@ pub struct ThoughtsObjectHandler {
|
||||
|
||||
impl ThoughtsObjectHandler {
|
||||
pub fn new(repo: Arc<dyn ActivityPubRepository>, base_url: &str) -> Self {
|
||||
Self { repo, urls: ThoughtsUrls::new(base_url) }
|
||||
Self {
|
||||
repo,
|
||||
urls: ThoughtsUrls::new(base_url),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,21 +31,34 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
||||
user_id: uuid::Uuid,
|
||||
) -> Result<Vec<(Url, serde_json::Value)>> {
|
||||
let uid = UserId::from_uuid(user_id);
|
||||
let entries = self.repo.outbox_entries_for_actor(&uid).await
|
||||
let entries = self
|
||||
.repo
|
||||
.outbox_entries_for_actor(&uid)
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
entries.into_iter().map(|e| {
|
||||
let note_url = self.urls.thought_url(e.thought.id.as_uuid());
|
||||
let actor_url = self.urls.user_url(e.author_username.as_str());
|
||||
let followers = self.urls.user_followers(e.author_username.as_str());
|
||||
let in_reply_to = e.thought.in_reply_to_id.map(|id| self.urls.thought_url(id.as_uuid()));
|
||||
let note = ThoughtNote::new_public(
|
||||
note_url.clone(), actor_url,
|
||||
e.thought.content.as_str().to_owned(),
|
||||
e.thought.created_at, in_reply_to,
|
||||
e.thought.sensitive, e.thought.content_warning, followers,
|
||||
);
|
||||
Ok((note_url, serde_json::to_value(¬e)?))
|
||||
}).collect()
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
let note_url = self.urls.thought_url(e.thought.id.as_uuid());
|
||||
let actor_url = self.urls.user_url(e.author_username.as_str());
|
||||
let followers = self.urls.user_followers(e.author_username.as_str());
|
||||
let in_reply_to = e
|
||||
.thought
|
||||
.in_reply_to_id
|
||||
.map(|id| self.urls.thought_url(id.as_uuid()));
|
||||
let note = ThoughtNote::new_public(
|
||||
note_url.clone(),
|
||||
actor_url,
|
||||
e.thought.content.as_str().to_owned(),
|
||||
e.thought.created_at,
|
||||
in_reply_to,
|
||||
e.thought.sensitive,
|
||||
e.thought.content_warning,
|
||||
followers,
|
||||
);
|
||||
Ok((note_url, serde_json::to_value(¬e)?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn get_local_objects_page(
|
||||
@@ -52,22 +68,35 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
||||
limit: usize,
|
||||
) -> Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>> {
|
||||
let uid = UserId::from_uuid(user_id);
|
||||
let entries = self.repo.outbox_page_for_actor(&uid, before, limit).await
|
||||
let entries = self
|
||||
.repo
|
||||
.outbox_page_for_actor(&uid, before, limit)
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
entries.into_iter().map(|e| {
|
||||
let created_at = e.thought.created_at;
|
||||
let note_url = self.urls.thought_url(e.thought.id.as_uuid());
|
||||
let actor_url = self.urls.user_url(e.author_username.as_str());
|
||||
let followers = self.urls.user_followers(e.author_username.as_str());
|
||||
let in_reply_to = e.thought.in_reply_to_id.map(|id| self.urls.thought_url(id.as_uuid()));
|
||||
let note = ThoughtNote::new_public(
|
||||
note_url.clone(), actor_url,
|
||||
e.thought.content.as_str().to_owned(),
|
||||
created_at, in_reply_to,
|
||||
e.thought.sensitive, e.thought.content_warning, followers,
|
||||
);
|
||||
Ok((note_url, serde_json::to_value(¬e)?, created_at))
|
||||
}).collect()
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
let created_at = e.thought.created_at;
|
||||
let note_url = self.urls.thought_url(e.thought.id.as_uuid());
|
||||
let actor_url = self.urls.user_url(e.author_username.as_str());
|
||||
let followers = self.urls.user_followers(e.author_username.as_str());
|
||||
let in_reply_to = e
|
||||
.thought
|
||||
.in_reply_to_id
|
||||
.map(|id| self.urls.thought_url(id.as_uuid()));
|
||||
let note = ThoughtNote::new_public(
|
||||
note_url.clone(),
|
||||
actor_url,
|
||||
e.thought.content.as_str().to_owned(),
|
||||
created_at,
|
||||
in_reply_to,
|
||||
e.thought.sensitive,
|
||||
e.thought.content_warning,
|
||||
followers,
|
||||
);
|
||||
Ok((note_url, serde_json::to_value(¬e)?, created_at))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn on_create(
|
||||
@@ -77,15 +106,22 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
||||
object: serde_json::Value,
|
||||
) -> Result<()> {
|
||||
let note: ThoughtNote = serde_json::from_value(object)?;
|
||||
let author_id = self.repo.intern_remote_actor(actor_url).await
|
||||
let author_id = self
|
||||
.repo
|
||||
.intern_remote_actor(actor_url)
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
self.repo.accept_note(
|
||||
ap_id, &author_id,
|
||||
¬e.content,
|
||||
note.published,
|
||||
note.sensitive,
|
||||
note.summary,
|
||||
).await.map_err(|e| anyhow!("{e}"))
|
||||
self.repo
|
||||
.accept_note(
|
||||
ap_id,
|
||||
&author_id,
|
||||
¬e.content,
|
||||
note.published,
|
||||
note.sensitive,
|
||||
note.summary,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
|
||||
async fn on_update(
|
||||
@@ -95,19 +131,30 @@ impl ApObjectHandler for ThoughtsObjectHandler {
|
||||
object: serde_json::Value,
|
||||
) -> Result<()> {
|
||||
let note: ThoughtNote = serde_json::from_value(object)?;
|
||||
self.repo.apply_note_update(ap_id, ¬e.content).await
|
||||
self.repo
|
||||
.apply_note_update(ap_id, ¬e.content)
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
|
||||
async fn on_delete(&self, ap_id: &Url, _actor_url: &Url) -> Result<()> {
|
||||
self.repo.retract_note(ap_id).await.map_err(|e| anyhow!("{e}"))
|
||||
self.repo
|
||||
.retract_note(ap_id)
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
|
||||
async fn on_actor_removed(&self, actor_url: &Url) -> Result<()> {
|
||||
self.repo.retract_actor_notes(actor_url).await.map_err(|e| anyhow!("{e}"))
|
||||
self.repo
|
||||
.retract_actor_notes(actor_url)
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> Result<u64> {
|
||||
self.repo.count_local_notes().await.map_err(|e| anyhow!("{e}"))
|
||||
self.repo
|
||||
.count_local_notes()
|
||||
.await
|
||||
.map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use activitypub_base::AS_PUBLIC;
|
||||
use activitypub_base::NoteType;
|
||||
use activitypub_base::AS_PUBLIC;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
@@ -27,16 +27,26 @@ pub struct ThoughtNote {
|
||||
|
||||
impl ThoughtNote {
|
||||
pub fn new_public(
|
||||
id: Url, actor_url: Url, content: String, published: DateTime<Utc>,
|
||||
in_reply_to: Option<Url>, sensitive: bool, summary: Option<String>,
|
||||
id: Url,
|
||||
actor_url: Url,
|
||||
content: String,
|
||||
published: DateTime<Utc>,
|
||||
in_reply_to: Option<Url>,
|
||||
sensitive: bool,
|
||||
summary: Option<String>,
|
||||
followers_url: Url,
|
||||
) -> Self {
|
||||
Self {
|
||||
kind: Default::default(),
|
||||
id, attributed_to: actor_url, content, published,
|
||||
id,
|
||||
attributed_to: actor_url,
|
||||
content,
|
||||
published,
|
||||
to: vec![AS_PUBLIC.to_string()],
|
||||
cc: vec![followers_url.to_string()],
|
||||
in_reply_to, sensitive, summary,
|
||||
in_reply_to,
|
||||
sensitive,
|
||||
summary,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,7 +62,9 @@ mod tests {
|
||||
"https://example.com/users/alice".parse().unwrap(),
|
||||
"Hello world".to_string(),
|
||||
chrono::Utc::now(),
|
||||
None, false, None,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
"https://example.com/users/alice/followers".parse().unwrap(),
|
||||
);
|
||||
let json = serde_json::to_string(¬e).unwrap();
|
||||
|
||||
@@ -6,7 +6,9 @@ pub struct ThoughtsUrls {
|
||||
|
||||
impl ThoughtsUrls {
|
||||
pub fn new(base_url: &str) -> Self {
|
||||
Self { base_url: base_url.trim_end_matches('/').to_string() }
|
||||
Self {
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn user_url(&self, username: &str) -> Url {
|
||||
@@ -37,13 +39,19 @@ mod tests {
|
||||
#[test]
|
||||
fn user_url_format() {
|
||||
let urls = ThoughtsUrls::new("https://example.com");
|
||||
assert_eq!(urls.user_url("alice").as_str(), "https://example.com/users/alice");
|
||||
assert_eq!(
|
||||
urls.user_url("alice").as_str(),
|
||||
"https://example.com/users/alice"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thought_url_format() {
|
||||
let urls = ThoughtsUrls::new("https://example.com");
|
||||
let id = uuid::Uuid::nil();
|
||||
assert!(urls.thought_url(id).as_str().starts_with("https://example.com/thoughts/"));
|
||||
assert!(urls
|
||||
.thought_url(id)
|
||||
.as_str()
|
||||
.starts_with("https://example.com/thoughts/"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{AuthService, GeneratedToken, PasswordHasher},
|
||||
value_objects::{PasswordHash, UserId},
|
||||
};
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Claims {
|
||||
@@ -21,7 +21,10 @@ pub struct JwtAuthService {
|
||||
|
||||
impl JwtAuthService {
|
||||
pub fn new(secret: String, ttl_seconds: i64) -> Self {
|
||||
Self { secret, ttl_seconds }
|
||||
Self {
|
||||
secret,
|
||||
ttl_seconds,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,8 +54,8 @@ impl AuthService for JwtAuthService {
|
||||
&Validation::default(),
|
||||
)
|
||||
.map_err(|_| DomainError::Unauthorized)?;
|
||||
let uuid = uuid::Uuid::parse_str(&data.claims.sub)
|
||||
.map_err(|_| DomainError::Unauthorized)?;
|
||||
let uuid =
|
||||
uuid::Uuid::parse_str(&data.claims.sub).map_err(|_| DomainError::Unauthorized)?;
|
||||
Ok(UserId::from_uuid(uuid))
|
||||
}
|
||||
}
|
||||
@@ -62,10 +65,7 @@ pub struct Argon2PasswordHasher;
|
||||
#[async_trait]
|
||||
impl PasswordHasher for Argon2PasswordHasher {
|
||||
async fn hash(&self, plain: &str) -> Result<PasswordHash, DomainError> {
|
||||
use argon2::{
|
||||
password_hash::SaltString,
|
||||
Argon2, PasswordHasher as _,
|
||||
};
|
||||
use argon2::{password_hash::SaltString, Argon2, PasswordHasher as _};
|
||||
use rand::rngs::OsRng;
|
||||
let salt = SaltString::generate(OsRng);
|
||||
let hash = Argon2::default()
|
||||
@@ -77,8 +77,7 @@ impl PasswordHasher for Argon2PasswordHasher {
|
||||
|
||||
async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
|
||||
use argon2::{password_hash::PasswordHash as ArgonHash, Argon2, PasswordVerifier};
|
||||
let parsed = ArgonHash::new(&hash.0)
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
let parsed = ArgonHash::new(&hash.0).map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
Ok(Argon2::default()
|
||||
.verify_password(plain.as_bytes(), &parsed)
|
||||
.is_ok())
|
||||
|
||||
@@ -74,20 +74,20 @@ impl EventPayload {
|
||||
/// Returns the NATS subject for this event.
|
||||
pub fn subject(&self) -> &'static str {
|
||||
match self {
|
||||
Self::ThoughtCreated { .. } => "thoughts.created",
|
||||
Self::ThoughtDeleted { .. } => "thoughts.deleted",
|
||||
Self::ThoughtUpdated { .. } => "thoughts.updated",
|
||||
Self::LikeAdded { .. } => "likes.added",
|
||||
Self::LikeRemoved { .. } => "likes.removed",
|
||||
Self::BoostAdded { .. } => "boosts.added",
|
||||
Self::BoostRemoved { .. } => "boosts.removed",
|
||||
Self::ThoughtCreated { .. } => "thoughts.created",
|
||||
Self::ThoughtDeleted { .. } => "thoughts.deleted",
|
||||
Self::ThoughtUpdated { .. } => "thoughts.updated",
|
||||
Self::LikeAdded { .. } => "likes.added",
|
||||
Self::LikeRemoved { .. } => "likes.removed",
|
||||
Self::BoostAdded { .. } => "boosts.added",
|
||||
Self::BoostRemoved { .. } => "boosts.removed",
|
||||
Self::FollowRequested { .. } => "follows.requested",
|
||||
Self::FollowAccepted { .. } => "follows.accepted",
|
||||
Self::FollowRejected { .. } => "follows.rejected",
|
||||
Self::Unfollowed { .. } => "follows.removed",
|
||||
Self::UserBlocked { .. } => "users.blocked",
|
||||
Self::UserUnblocked { .. } => "users.unblocked",
|
||||
Self::UserRegistered { .. } => "users.registered",
|
||||
Self::FollowAccepted { .. } => "follows.accepted",
|
||||
Self::FollowRejected { .. } => "follows.rejected",
|
||||
Self::Unfollowed { .. } => "follows.removed",
|
||||
Self::UserBlocked { .. } => "users.blocked",
|
||||
Self::UserUnblocked { .. } => "users.unblocked",
|
||||
Self::UserRegistered { .. } => "users.registered",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,46 +97,102 @@ impl EventPayload {
|
||||
impl From<&DomainEvent> for EventPayload {
|
||||
fn from(e: &DomainEvent) -> Self {
|
||||
match e {
|
||||
DomainEvent::ThoughtCreated { thought_id, user_id, in_reply_to_id } => Self::ThoughtCreated {
|
||||
DomainEvent::ThoughtCreated {
|
||||
thought_id,
|
||||
user_id,
|
||||
in_reply_to_id,
|
||||
} => Self::ThoughtCreated {
|
||||
thought_id: thought_id.to_string(),
|
||||
user_id: user_id.to_string(),
|
||||
in_reply_to_id: in_reply_to_id.as_ref().map(|x| x.to_string()),
|
||||
},
|
||||
DomainEvent::ThoughtDeleted { thought_id, user_id } => Self::ThoughtDeleted {
|
||||
thought_id: thought_id.to_string(), user_id: user_id.to_string(),
|
||||
DomainEvent::ThoughtDeleted {
|
||||
thought_id,
|
||||
user_id,
|
||||
} => Self::ThoughtDeleted {
|
||||
thought_id: thought_id.to_string(),
|
||||
user_id: user_id.to_string(),
|
||||
},
|
||||
DomainEvent::ThoughtUpdated { thought_id, user_id } => Self::ThoughtUpdated {
|
||||
thought_id: thought_id.to_string(), user_id: user_id.to_string(),
|
||||
DomainEvent::ThoughtUpdated {
|
||||
thought_id,
|
||||
user_id,
|
||||
} => Self::ThoughtUpdated {
|
||||
thought_id: thought_id.to_string(),
|
||||
user_id: user_id.to_string(),
|
||||
},
|
||||
DomainEvent::LikeAdded { like_id, user_id, thought_id } => Self::LikeAdded {
|
||||
like_id: like_id.to_string(), user_id: user_id.to_string(), thought_id: thought_id.to_string(),
|
||||
DomainEvent::LikeAdded {
|
||||
like_id,
|
||||
user_id,
|
||||
thought_id,
|
||||
} => Self::LikeAdded {
|
||||
like_id: like_id.to_string(),
|
||||
user_id: user_id.to_string(),
|
||||
thought_id: thought_id.to_string(),
|
||||
},
|
||||
DomainEvent::LikeRemoved { user_id, thought_id } => Self::LikeRemoved {
|
||||
user_id: user_id.to_string(), thought_id: thought_id.to_string(),
|
||||
DomainEvent::LikeRemoved {
|
||||
user_id,
|
||||
thought_id,
|
||||
} => Self::LikeRemoved {
|
||||
user_id: user_id.to_string(),
|
||||
thought_id: thought_id.to_string(),
|
||||
},
|
||||
DomainEvent::BoostAdded { boost_id, user_id, thought_id } => Self::BoostAdded {
|
||||
boost_id: boost_id.to_string(), user_id: user_id.to_string(), thought_id: thought_id.to_string(),
|
||||
DomainEvent::BoostAdded {
|
||||
boost_id,
|
||||
user_id,
|
||||
thought_id,
|
||||
} => Self::BoostAdded {
|
||||
boost_id: boost_id.to_string(),
|
||||
user_id: user_id.to_string(),
|
||||
thought_id: thought_id.to_string(),
|
||||
},
|
||||
DomainEvent::BoostRemoved { user_id, thought_id } => Self::BoostRemoved {
|
||||
user_id: user_id.to_string(), thought_id: thought_id.to_string(),
|
||||
DomainEvent::BoostRemoved {
|
||||
user_id,
|
||||
thought_id,
|
||||
} => Self::BoostRemoved {
|
||||
user_id: user_id.to_string(),
|
||||
thought_id: thought_id.to_string(),
|
||||
},
|
||||
DomainEvent::FollowRequested { follower_id, following_id } => Self::FollowRequested {
|
||||
follower_id: follower_id.to_string(), following_id: following_id.to_string(),
|
||||
DomainEvent::FollowRequested {
|
||||
follower_id,
|
||||
following_id,
|
||||
} => Self::FollowRequested {
|
||||
follower_id: follower_id.to_string(),
|
||||
following_id: following_id.to_string(),
|
||||
},
|
||||
DomainEvent::FollowAccepted { follower_id, following_id } => Self::FollowAccepted {
|
||||
follower_id: follower_id.to_string(), following_id: following_id.to_string(),
|
||||
DomainEvent::FollowAccepted {
|
||||
follower_id,
|
||||
following_id,
|
||||
} => Self::FollowAccepted {
|
||||
follower_id: follower_id.to_string(),
|
||||
following_id: following_id.to_string(),
|
||||
},
|
||||
DomainEvent::FollowRejected { follower_id, following_id } => Self::FollowRejected {
|
||||
follower_id: follower_id.to_string(), following_id: following_id.to_string(),
|
||||
DomainEvent::FollowRejected {
|
||||
follower_id,
|
||||
following_id,
|
||||
} => Self::FollowRejected {
|
||||
follower_id: follower_id.to_string(),
|
||||
following_id: following_id.to_string(),
|
||||
},
|
||||
DomainEvent::Unfollowed { follower_id, following_id } => Self::Unfollowed {
|
||||
follower_id: follower_id.to_string(), following_id: following_id.to_string(),
|
||||
DomainEvent::Unfollowed {
|
||||
follower_id,
|
||||
following_id,
|
||||
} => Self::Unfollowed {
|
||||
follower_id: follower_id.to_string(),
|
||||
following_id: following_id.to_string(),
|
||||
},
|
||||
DomainEvent::UserBlocked { blocker_id, blocked_id } => Self::UserBlocked {
|
||||
blocker_id: blocker_id.to_string(), blocked_id: blocked_id.to_string(),
|
||||
DomainEvent::UserBlocked {
|
||||
blocker_id,
|
||||
blocked_id,
|
||||
} => Self::UserBlocked {
|
||||
blocker_id: blocker_id.to_string(),
|
||||
blocked_id: blocked_id.to_string(),
|
||||
},
|
||||
DomainEvent::UserUnblocked { blocker_id, blocked_id } => Self::UserUnblocked {
|
||||
blocker_id: blocker_id.to_string(), blocked_id: blocked_id.to_string(),
|
||||
DomainEvent::UserUnblocked {
|
||||
blocker_id,
|
||||
blocked_id,
|
||||
} => Self::UserUnblocked {
|
||||
blocker_id: blocker_id.to_string(),
|
||||
blocked_id: blocked_id.to_string(),
|
||||
},
|
||||
DomainEvent::UserRegistered { user_id } => Self::UserRegistered {
|
||||
user_id: user_id.to_string(),
|
||||
@@ -157,60 +213,102 @@ impl TryFrom<EventPayload> for DomainEvent {
|
||||
|
||||
fn try_from(p: EventPayload) -> Result<Self, DomainError> {
|
||||
Ok(match p {
|
||||
EventPayload::ThoughtCreated { thought_id, user_id, in_reply_to_id } => DomainEvent::ThoughtCreated {
|
||||
EventPayload::ThoughtCreated {
|
||||
thought_id,
|
||||
user_id,
|
||||
in_reply_to_id,
|
||||
} => DomainEvent::ThoughtCreated {
|
||||
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
in_reply_to_id: in_reply_to_id
|
||||
.map(|s| parse_uuid(&s, "in_reply_to_id").map(ThoughtId::from_uuid))
|
||||
.transpose()?,
|
||||
},
|
||||
EventPayload::ThoughtDeleted { thought_id, user_id } => DomainEvent::ThoughtDeleted {
|
||||
EventPayload::ThoughtDeleted {
|
||||
thought_id,
|
||||
user_id,
|
||||
} => DomainEvent::ThoughtDeleted {
|
||||
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
},
|
||||
EventPayload::ThoughtUpdated { thought_id, user_id } => DomainEvent::ThoughtUpdated {
|
||||
EventPayload::ThoughtUpdated {
|
||||
thought_id,
|
||||
user_id,
|
||||
} => DomainEvent::ThoughtUpdated {
|
||||
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
},
|
||||
EventPayload::LikeAdded { like_id, user_id, thought_id } => DomainEvent::LikeAdded {
|
||||
EventPayload::LikeAdded {
|
||||
like_id,
|
||||
user_id,
|
||||
thought_id,
|
||||
} => DomainEvent::LikeAdded {
|
||||
like_id: LikeId::from_uuid(parse_uuid(&like_id, "like_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||
},
|
||||
EventPayload::LikeRemoved { user_id, thought_id } => DomainEvent::LikeRemoved {
|
||||
EventPayload::LikeRemoved {
|
||||
user_id,
|
||||
thought_id,
|
||||
} => DomainEvent::LikeRemoved {
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||
},
|
||||
EventPayload::BoostAdded { boost_id, user_id, thought_id } => DomainEvent::BoostAdded {
|
||||
EventPayload::BoostAdded {
|
||||
boost_id,
|
||||
user_id,
|
||||
thought_id,
|
||||
} => DomainEvent::BoostAdded {
|
||||
boost_id: BoostId::from_uuid(parse_uuid(&boost_id, "boost_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||
},
|
||||
EventPayload::BoostRemoved { user_id, thought_id } => DomainEvent::BoostRemoved {
|
||||
EventPayload::BoostRemoved {
|
||||
user_id,
|
||||
thought_id,
|
||||
} => DomainEvent::BoostRemoved {
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||
},
|
||||
EventPayload::FollowRequested { follower_id, following_id } => DomainEvent::FollowRequested {
|
||||
EventPayload::FollowRequested {
|
||||
follower_id,
|
||||
following_id,
|
||||
} => DomainEvent::FollowRequested {
|
||||
follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
|
||||
following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?),
|
||||
},
|
||||
EventPayload::FollowAccepted { follower_id, following_id } => DomainEvent::FollowAccepted {
|
||||
EventPayload::FollowAccepted {
|
||||
follower_id,
|
||||
following_id,
|
||||
} => DomainEvent::FollowAccepted {
|
||||
follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
|
||||
following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?),
|
||||
},
|
||||
EventPayload::FollowRejected { follower_id, following_id } => DomainEvent::FollowRejected {
|
||||
EventPayload::FollowRejected {
|
||||
follower_id,
|
||||
following_id,
|
||||
} => DomainEvent::FollowRejected {
|
||||
follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
|
||||
following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?),
|
||||
},
|
||||
EventPayload::Unfollowed { follower_id, following_id } => DomainEvent::Unfollowed {
|
||||
EventPayload::Unfollowed {
|
||||
follower_id,
|
||||
following_id,
|
||||
} => DomainEvent::Unfollowed {
|
||||
follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
|
||||
following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?),
|
||||
},
|
||||
EventPayload::UserBlocked { blocker_id, blocked_id } => DomainEvent::UserBlocked {
|
||||
EventPayload::UserBlocked {
|
||||
blocker_id,
|
||||
blocked_id,
|
||||
} => DomainEvent::UserBlocked {
|
||||
blocker_id: UserId::from_uuid(parse_uuid(&blocker_id, "blocker_id")?),
|
||||
blocked_id: UserId::from_uuid(parse_uuid(&blocked_id, "blocked_id")?),
|
||||
},
|
||||
EventPayload::UserUnblocked { blocker_id, blocked_id } => DomainEvent::UserUnblocked {
|
||||
EventPayload::UserUnblocked {
|
||||
blocker_id,
|
||||
blocked_id,
|
||||
} => DomainEvent::UserUnblocked {
|
||||
blocker_id: UserId::from_uuid(parse_uuid(&blocker_id, "blocker_id")?),
|
||||
blocked_id: UserId::from_uuid(parse_uuid(&blocked_id, "blocked_id")?),
|
||||
},
|
||||
@@ -240,22 +338,65 @@ mod tests {
|
||||
#[test]
|
||||
fn all_subjects_are_unique() {
|
||||
let samples: &[EventPayload] = &[
|
||||
EventPayload::ThoughtCreated { thought_id: "a".into(), user_id: "b".into(), in_reply_to_id: None },
|
||||
EventPayload::ThoughtDeleted { thought_id: "a".into(), user_id: "b".into() },
|
||||
EventPayload::ThoughtUpdated { thought_id: "a".into(), user_id: "b".into() },
|
||||
EventPayload::LikeAdded { like_id: "a".into(), user_id: "b".into(), thought_id: "c".into() },
|
||||
EventPayload::LikeRemoved { user_id: "b".into(), thought_id: "c".into() },
|
||||
EventPayload::BoostAdded { boost_id: "a".into(), user_id: "b".into(), thought_id: "c".into() },
|
||||
EventPayload::BoostRemoved { user_id: "b".into(), thought_id: "c".into() },
|
||||
EventPayload::FollowRequested { follower_id: "a".into(), following_id: "b".into() },
|
||||
EventPayload::FollowAccepted { follower_id: "a".into(), following_id: "b".into() },
|
||||
EventPayload::FollowRejected { follower_id: "a".into(), following_id: "b".into() },
|
||||
EventPayload::Unfollowed { follower_id: "a".into(), following_id: "b".into() },
|
||||
EventPayload::UserBlocked { blocker_id: "a".into(), blocked_id: "b".into() },
|
||||
EventPayload::ThoughtCreated {
|
||||
thought_id: "a".into(),
|
||||
user_id: "b".into(),
|
||||
in_reply_to_id: None,
|
||||
},
|
||||
EventPayload::ThoughtDeleted {
|
||||
thought_id: "a".into(),
|
||||
user_id: "b".into(),
|
||||
},
|
||||
EventPayload::ThoughtUpdated {
|
||||
thought_id: "a".into(),
|
||||
user_id: "b".into(),
|
||||
},
|
||||
EventPayload::LikeAdded {
|
||||
like_id: "a".into(),
|
||||
user_id: "b".into(),
|
||||
thought_id: "c".into(),
|
||||
},
|
||||
EventPayload::LikeRemoved {
|
||||
user_id: "b".into(),
|
||||
thought_id: "c".into(),
|
||||
},
|
||||
EventPayload::BoostAdded {
|
||||
boost_id: "a".into(),
|
||||
user_id: "b".into(),
|
||||
thought_id: "c".into(),
|
||||
},
|
||||
EventPayload::BoostRemoved {
|
||||
user_id: "b".into(),
|
||||
thought_id: "c".into(),
|
||||
},
|
||||
EventPayload::FollowRequested {
|
||||
follower_id: "a".into(),
|
||||
following_id: "b".into(),
|
||||
},
|
||||
EventPayload::FollowAccepted {
|
||||
follower_id: "a".into(),
|
||||
following_id: "b".into(),
|
||||
},
|
||||
EventPayload::FollowRejected {
|
||||
follower_id: "a".into(),
|
||||
following_id: "b".into(),
|
||||
},
|
||||
EventPayload::Unfollowed {
|
||||
follower_id: "a".into(),
|
||||
following_id: "b".into(),
|
||||
},
|
||||
EventPayload::UserBlocked {
|
||||
blocker_id: "a".into(),
|
||||
blocked_id: "b".into(),
|
||||
},
|
||||
];
|
||||
let mut subjects: Vec<&str> = samples.iter().map(|p| p.subject()).collect();
|
||||
subjects.sort();
|
||||
subjects.dedup();
|
||||
assert_eq!(subjects.len(), samples.len(), "each event must have a unique subject");
|
||||
assert_eq!(
|
||||
subjects.len(),
|
||||
samples.len(),
|
||||
"each event must have a unique subject"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, events::{DomainEvent, EventEnvelope}, ports::{EventConsumer, EventPublisher}};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::{DomainEvent, EventEnvelope},
|
||||
ports::{EventConsumer, EventPublisher},
|
||||
};
|
||||
use event_payload::EventPayload;
|
||||
use futures::stream::BoxStream;
|
||||
|
||||
@@ -31,8 +35,8 @@ impl<T: Transport> EventPublisher for EventPublisherAdapter<T> {
|
||||
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
let payload = EventPayload::from(event);
|
||||
let subject = payload.subject();
|
||||
let bytes = serde_json::to_vec(&payload)
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
let bytes =
|
||||
serde_json::to_vec(&payload).map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
tracing::debug!(subject, "publishing event");
|
||||
self.transport.publish_bytes(subject, &bytes).await
|
||||
}
|
||||
@@ -44,7 +48,7 @@ impl<T: Transport> EventPublisher for EventPublisherAdapter<T> {
|
||||
pub struct RawMessage {
|
||||
pub subject: String,
|
||||
pub payload: Vec<u8>,
|
||||
pub ack: Box<dyn Fn() + Send + Sync>,
|
||||
pub ack: Box<dyn Fn() + Send + Sync>,
|
||||
pub nack: Box<dyn Fn() + Send + Sync>,
|
||||
}
|
||||
|
||||
@@ -60,7 +64,9 @@ pub struct EventConsumerAdapter<S: MessageSource> {
|
||||
}
|
||||
|
||||
impl<S: MessageSource> EventConsumerAdapter<S> {
|
||||
pub fn new(source: S) -> Self { Self { source } }
|
||||
pub fn new(source: S) -> Self {
|
||||
Self { source }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: MessageSource> EventConsumer for EventConsumerAdapter<S> {
|
||||
@@ -90,7 +96,7 @@ impl<S: MessageSource> EventConsumer for EventConsumerAdapter<S> {
|
||||
};
|
||||
Some(Ok(EventEnvelope {
|
||||
event,
|
||||
ack: msg.ack,
|
||||
ack: msg.ack,
|
||||
nack: msg.nack,
|
||||
}))
|
||||
}
|
||||
@@ -103,8 +109,8 @@ impl<S: MessageSource> EventConsumer for EventConsumerAdapter<S> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use domain::value_objects::{ThoughtId, UserId};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
struct SpyTransport {
|
||||
calls: Arc<Mutex<Vec<(String, Vec<u8>)>>>,
|
||||
@@ -112,13 +118,21 @@ mod tests {
|
||||
impl SpyTransport {
|
||||
fn new() -> (Self, Arc<Mutex<Vec<(String, Vec<u8>)>>>) {
|
||||
let calls = Arc::new(Mutex::new(vec![]));
|
||||
(Self { calls: calls.clone() }, calls)
|
||||
(
|
||||
Self {
|
||||
calls: calls.clone(),
|
||||
},
|
||||
calls,
|
||||
)
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl Transport for SpyTransport {
|
||||
async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError> {
|
||||
self.calls.lock().unwrap().push((subject.to_string(), bytes.to_vec()));
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((subject.to_string(), bytes.to_vec()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -127,11 +141,14 @@ mod tests {
|
||||
async fn thought_created_routes_to_correct_subject() {
|
||||
let (spy, calls) = SpyTransport::new();
|
||||
let publisher = EventPublisherAdapter::new(spy);
|
||||
publisher.publish(&DomainEvent::ThoughtCreated {
|
||||
thought_id: ThoughtId::new(),
|
||||
user_id: UserId::new(),
|
||||
in_reply_to_id: None,
|
||||
}).await.unwrap();
|
||||
publisher
|
||||
.publish(&DomainEvent::ThoughtCreated {
|
||||
thought_id: ThoughtId::new(),
|
||||
user_id: UserId::new(),
|
||||
in_reply_to_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let calls = calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].0, "thoughts.created");
|
||||
@@ -141,10 +158,13 @@ mod tests {
|
||||
async fn serialized_payload_is_valid_json() {
|
||||
let (spy, calls) = SpyTransport::new();
|
||||
let publisher = EventPublisherAdapter::new(spy);
|
||||
publisher.publish(&DomainEvent::UserBlocked {
|
||||
blocker_id: UserId::new(),
|
||||
blocked_id: UserId::new(),
|
||||
}).await.unwrap();
|
||||
publisher
|
||||
.publish(&DomainEvent::UserBlocked {
|
||||
blocker_id: UserId::new(),
|
||||
blocked_id: UserId::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let bytes = calls.lock().unwrap()[0].1.clone();
|
||||
let json: serde_json::Value = serde_json::from_slice(&bytes).expect("valid JSON");
|
||||
assert_eq!(json["type"], "UserBlocked");
|
||||
@@ -163,14 +183,16 @@ mod tests {
|
||||
let payload = EventPayload::from(&event);
|
||||
let bytes = serde_json::to_vec(&payload).unwrap();
|
||||
|
||||
struct OneMessageSource { bytes: Vec<u8> }
|
||||
struct OneMessageSource {
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl MessageSource for OneMessageSource {
|
||||
fn messages(&self) -> futures::stream::BoxStream<'_, Result<RawMessage, DomainError>> {
|
||||
let msg = RawMessage {
|
||||
subject: "thoughts.created".to_string(),
|
||||
payload: self.bytes.clone(),
|
||||
ack: Box::new(|| {}),
|
||||
ack: Box::new(|| {}),
|
||||
nack: Box::new(|| {}),
|
||||
};
|
||||
Box::pin(futures::stream::once(async { Ok(msg) }))
|
||||
@@ -194,7 +216,7 @@ mod tests {
|
||||
let msg = RawMessage {
|
||||
subject: "bad".to_string(),
|
||||
payload: b"not valid json".to_vec(),
|
||||
ack: Box::new(|| {}),
|
||||
ack: Box::new(|| {}),
|
||||
nack: Box::new(|| {}),
|
||||
};
|
||||
Box::pin(futures::stream::once(async { Ok(msg) }))
|
||||
|
||||
@@ -10,7 +10,9 @@ pub struct NatsTransport {
|
||||
}
|
||||
|
||||
impl NatsTransport {
|
||||
pub fn new(client: async_nats::Client) -> Self { Self { client } }
|
||||
pub fn new(client: async_nats::Client) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -30,7 +32,9 @@ pub struct NatsMessageSource {
|
||||
}
|
||||
|
||||
impl NatsMessageSource {
|
||||
pub fn new(client: async_nats::Client) -> Self { Self { client } }
|
||||
pub fn new(client: async_nats::Client) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
impl MessageSource for NatsMessageSource {
|
||||
@@ -61,7 +65,10 @@ impl MessageSource for NatsMessageSource {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::{events::DomainEvent, value_objects::{LikeId, ThoughtId, UserId}};
|
||||
use domain::{
|
||||
events::DomainEvent,
|
||||
value_objects::{LikeId, ThoughtId, UserId},
|
||||
};
|
||||
use event_payload::EventPayload;
|
||||
|
||||
#[test]
|
||||
@@ -86,7 +93,12 @@ mod tests {
|
||||
};
|
||||
let payload = EventPayload::from(&event);
|
||||
let back = DomainEvent::try_from(payload).unwrap();
|
||||
if let DomainEvent::LikeAdded { user_id, thought_id, .. } = back {
|
||||
if let DomainEvent::LikeAdded {
|
||||
user_id,
|
||||
thought_id,
|
||||
..
|
||||
} = back
|
||||
{
|
||||
assert_eq!(user_id, uid);
|
||||
assert_eq!(thought_id, tid);
|
||||
} else {
|
||||
|
||||
@@ -4,8 +4,8 @@ use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use activitypub_base::{
|
||||
ApUser, ApUserRepository,
|
||||
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
||||
ApUser, ApUserRepository, BlockedDomain, FederationRepository, Follower, FollowerStatus,
|
||||
FollowingStatus, RemoteActor,
|
||||
};
|
||||
|
||||
// ── PostgresFederationRepository ─────────────────────────────────────────────
|
||||
@@ -15,29 +15,54 @@ pub struct PostgresFederationRepository {
|
||||
}
|
||||
|
||||
impl PostgresFederationRepository {
|
||||
pub fn new(pool: PgPool) -> Self { Self { pool } }
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
fn status_str(s: &FollowerStatus) -> &'static str {
|
||||
match s { FollowerStatus::Pending => "pending", FollowerStatus::Accepted => "accepted", FollowerStatus::Rejected => "rejected" }
|
||||
match s {
|
||||
FollowerStatus::Pending => "pending",
|
||||
FollowerStatus::Accepted => "accepted",
|
||||
FollowerStatus::Rejected => "rejected",
|
||||
}
|
||||
}
|
||||
fn str_status(s: &str) -> FollowerStatus {
|
||||
match s { "accepted" => FollowerStatus::Accepted, "rejected" => FollowerStatus::Rejected, _ => FollowerStatus::Pending }
|
||||
match s {
|
||||
"accepted" => FollowerStatus::Accepted,
|
||||
"rejected" => FollowerStatus::Rejected,
|
||||
_ => FollowerStatus::Pending,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_remote_actor(
|
||||
url: String, handle: String, inbox_url: String,
|
||||
shared_inbox_url: Option<String>, display_name: Option<String>,
|
||||
avatar_url: Option<String>, outbox_url: Option<String>,
|
||||
url: String,
|
||||
handle: String,
|
||||
inbox_url: String,
|
||||
shared_inbox_url: Option<String>,
|
||||
display_name: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
outbox_url: Option<String>,
|
||||
) -> RemoteActor {
|
||||
RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url }
|
||||
RemoteActor {
|
||||
url,
|
||||
handle,
|
||||
inbox_url,
|
||||
shared_inbox_url,
|
||||
display_name,
|
||||
avatar_url,
|
||||
outbox_url,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FederationRepository for PostgresFederationRepository {
|
||||
async fn add_follower(
|
||||
&self, local_user_id: uuid::Uuid, remote_actor_url: &str,
|
||||
status: FollowerStatus, follow_activity_id: &str,
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
status: FollowerStatus,
|
||||
follow_activity_id: &str,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO federation_followers(local_user_id,remote_actor_url,status,follow_activity_id)
|
||||
@@ -50,22 +75,43 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
}
|
||||
|
||||
async fn get_follower_follow_activity_id(
|
||||
&self, local_user_id: uuid::Uuid, remote_actor_url: &str,
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
) -> Result<Option<String>> {
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT follow_activity_id FROM federation_followers WHERE local_user_id=$1 AND remote_actor_url=$2"
|
||||
).bind(local_user_id).bind(remote_actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))
|
||||
}
|
||||
|
||||
async fn remove_follower(&self, local_user_id: uuid::Uuid, remote_actor_url: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM federation_followers WHERE local_user_id=$1 AND remote_actor_url=$2")
|
||||
.bind(local_user_id).bind(remote_actor_url)
|
||||
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
||||
async fn remove_follower(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"DELETE FROM federation_followers WHERE local_user_id=$1 AND remote_actor_url=$2",
|
||||
)
|
||||
.bind(local_user_id)
|
||||
.bind(remote_actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row { remote_actor_url: String, status: String, handle: String, inbox_url: String, shared_inbox_url: Option<String>, display_name: Option<String>, avatar_url: Option<String>, outbox_url: Option<String> }
|
||||
struct Row {
|
||||
remote_actor_url: String,
|
||||
status: String,
|
||||
handle: String,
|
||||
inbox_url: String,
|
||||
shared_inbox_url: Option<String>,
|
||||
display_name: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
outbox_url: Option<String>,
|
||||
}
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT f.remote_actor_url, f.status, COALESCE(r.handle,'') AS handle,
|
||||
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
|
||||
@@ -79,10 +125,22 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
}
|
||||
|
||||
async fn get_followers_page(
|
||||
&self, local_user_id: uuid::Uuid, offset: u32, limit: usize,
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
offset: u32,
|
||||
limit: usize,
|
||||
) -> Result<Vec<Follower>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row { remote_actor_url: String, status: String, handle: String, inbox_url: String, shared_inbox_url: Option<String>, display_name: Option<String>, avatar_url: Option<String>, outbox_url: Option<String> }
|
||||
struct Row {
|
||||
remote_actor_url: String,
|
||||
status: String,
|
||||
handle: String,
|
||||
inbox_url: String,
|
||||
shared_inbox_url: Option<String>,
|
||||
display_name: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
outbox_url: Option<String>,
|
||||
}
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT f.remote_actor_url, f.status, COALESCE(r.handle,'') AS handle,
|
||||
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
|
||||
@@ -105,7 +163,15 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
|
||||
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row { remote_actor_url: String, handle: String, inbox_url: String, shared_inbox_url: Option<String>, display_name: Option<String>, avatar_url: Option<String>, outbox_url: Option<String> }
|
||||
struct Row {
|
||||
remote_actor_url: String,
|
||||
handle: String,
|
||||
inbox_url: String,
|
||||
shared_inbox_url: Option<String>,
|
||||
display_name: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
outbox_url: Option<String>,
|
||||
}
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle,
|
||||
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
|
||||
@@ -118,7 +184,10 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
}
|
||||
|
||||
async fn update_follower_status(
|
||||
&self, local_user_id: uuid::Uuid, remote_actor_url: &str, status: FollowerStatus,
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
status: FollowerStatus,
|
||||
) -> Result<()> {
|
||||
sqlx::query("UPDATE federation_followers SET status=$3 WHERE local_user_id=$1 AND remote_actor_url=$2")
|
||||
.bind(local_user_id).bind(remote_actor_url).bind(status_str(&status))
|
||||
@@ -126,7 +195,10 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
}
|
||||
|
||||
async fn add_following(
|
||||
&self, local_user_id: uuid::Uuid, actor: RemoteActor, follow_activity_id: &str,
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
actor: RemoteActor,
|
||||
follow_activity_id: &str,
|
||||
) -> Result<()> {
|
||||
self.upsert_remote_actor(actor.clone()).await?;
|
||||
sqlx::query(
|
||||
@@ -140,7 +212,9 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
}
|
||||
|
||||
async fn get_follow_activity_id(
|
||||
&self, local_user_id: uuid::Uuid, remote_actor_url: &str,
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
) -> Result<Option<String>> {
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT follow_activity_id FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2"
|
||||
@@ -148,14 +222,28 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
}
|
||||
|
||||
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2")
|
||||
.bind(local_user_id).bind(actor_url)
|
||||
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
||||
sqlx::query(
|
||||
"DELETE FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2",
|
||||
)
|
||||
.bind(local_user_id)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row { remote_actor_url: String, handle: String, inbox_url: String, shared_inbox_url: Option<String>, display_name: Option<String>, avatar_url: Option<String>, outbox_url: Option<String> }
|
||||
struct Row {
|
||||
remote_actor_url: String,
|
||||
handle: String,
|
||||
inbox_url: String,
|
||||
shared_inbox_url: Option<String>,
|
||||
display_name: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
outbox_url: Option<String>,
|
||||
}
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle,
|
||||
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
|
||||
@@ -168,10 +256,21 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
}
|
||||
|
||||
async fn get_following_page(
|
||||
&self, local_user_id: uuid::Uuid, offset: u32, limit: usize,
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
offset: u32,
|
||||
limit: usize,
|
||||
) -> Result<Vec<RemoteActor>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row { remote_actor_url: String, handle: String, inbox_url: String, shared_inbox_url: Option<String>, display_name: Option<String>, avatar_url: Option<String>, outbox_url: Option<String> }
|
||||
struct Row {
|
||||
remote_actor_url: String,
|
||||
handle: String,
|
||||
inbox_url: String,
|
||||
shared_inbox_url: Option<String>,
|
||||
display_name: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
outbox_url: Option<String>,
|
||||
}
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle,
|
||||
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
|
||||
@@ -185,20 +284,28 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
}
|
||||
|
||||
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> {
|
||||
let n: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM federation_following WHERE local_user_id=$1"
|
||||
).bind(local_user_id).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?;
|
||||
let n: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM federation_following WHERE local_user_id=$1")
|
||||
.bind(local_user_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))?;
|
||||
Ok(n as usize)
|
||||
}
|
||||
|
||||
async fn update_following_status(
|
||||
&self, _local_user_id: uuid::Uuid, _remote_actor_url: &str, _status: FollowingStatus,
|
||||
&self,
|
||||
_local_user_id: uuid::Uuid,
|
||||
_remote_actor_url: &str,
|
||||
_status: FollowingStatus,
|
||||
) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_following_outbox_url(
|
||||
&self, local_user_id: uuid::Uuid, remote_actor_url: &str,
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
) -> Result<Option<String>> {
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT outbox_url FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2"
|
||||
@@ -221,7 +328,15 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
|
||||
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row { url: String, handle: String, inbox_url: String, shared_inbox_url: Option<String>, display_name: Option<String>, avatar_url: Option<String>, outbox_url: Option<String> }
|
||||
struct Row {
|
||||
url: String,
|
||||
handle: String,
|
||||
inbox_url: String,
|
||||
shared_inbox_url: Option<String>,
|
||||
display_name: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
outbox_url: Option<String>,
|
||||
}
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT url,handle,inbox_url,shared_inbox_url,display_name,avatar_url,outbox_url FROM remote_actors WHERE url=$1"
|
||||
).bind(actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e)).map(|o| o.map(|r|
|
||||
@@ -229,12 +344,22 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_local_actor_keypair(&self, user_id: uuid::Uuid) -> Result<Option<(String, String)>> {
|
||||
async fn get_local_actor_keypair(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Result<Option<(String, String)>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row { public_key: Option<String>, private_key: Option<String> }
|
||||
struct Row {
|
||||
public_key: Option<String>,
|
||||
private_key: Option<String>,
|
||||
}
|
||||
let row = sqlx::query_as::<_, Row>(
|
||||
"SELECT public_key, private_key FROM users WHERE id=$1 AND local=true"
|
||||
).bind(user_id).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))?;
|
||||
"SELECT public_key, private_key FROM users WHERE id=$1 AND local=true",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))?;
|
||||
Ok(row.and_then(|r| match (r.public_key, r.private_key) {
|
||||
(Some(pub_k), Some(priv_k)) => Some((pub_k, priv_k)),
|
||||
_ => None,
|
||||
@@ -242,27 +367,49 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
}
|
||||
|
||||
async fn save_local_actor_keypair(
|
||||
&self, user_id: uuid::Uuid, public_key: String, private_key: String,
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
public_key: String,
|
||||
private_key: String,
|
||||
) -> Result<()> {
|
||||
sqlx::query("UPDATE users SET public_key=$2, private_key=$3, updated_at=NOW() WHERE id=$1")
|
||||
.bind(user_id).bind(&public_key).bind(&private_key)
|
||||
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
||||
.bind(user_id)
|
||||
.bind(&public_key)
|
||||
.bind(&private_key)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn add_announce(
|
||||
&self, activity_id: &str, object_url: &str, actor_url: &str, announced_at: DateTime<Utc>,
|
||||
&self,
|
||||
activity_id: &str,
|
||||
object_url: &str,
|
||||
actor_url: &str,
|
||||
announced_at: DateTime<Utc>,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO federation_announces(activity_id,object_url,actor_url,announced_at)
|
||||
VALUES($1,$2,$3,$4) ON CONFLICT(activity_id) DO NOTHING"
|
||||
).bind(activity_id).bind(object_url).bind(actor_url).bind(announced_at)
|
||||
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
||||
VALUES($1,$2,$3,$4) ON CONFLICT(activity_id) DO NOTHING",
|
||||
)
|
||||
.bind(activity_id)
|
||||
.bind(object_url)
|
||||
.bind(actor_url)
|
||||
.bind(announced_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn count_announces(&self, object_url: &str) -> Result<usize> {
|
||||
let n: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM federation_announces WHERE object_url=$1"
|
||||
).bind(object_url).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?;
|
||||
let n: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM federation_announces WHERE object_url=$1")
|
||||
.bind(object_url)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))?;
|
||||
Ok(n as usize)
|
||||
}
|
||||
|
||||
@@ -274,21 +421,44 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
|
||||
async fn remove_blocked_domain(&self, domain: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM federation_blocked_domains WHERE domain=$1")
|
||||
.bind(domain).execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
||||
.bind(domain)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row { domain: String, reason: Option<String>, blocked_at: DateTime<Utc> }
|
||||
sqlx::query_as::<_, Row>("SELECT domain,reason,blocked_at FROM federation_blocked_domains ORDER BY domain")
|
||||
.fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r|
|
||||
BlockedDomain { domain: r.domain, reason: r.reason, blocked_at: r.blocked_at.to_rfc3339() }
|
||||
).collect())
|
||||
struct Row {
|
||||
domain: String,
|
||||
reason: Option<String>,
|
||||
blocked_at: DateTime<Utc>,
|
||||
}
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT domain,reason,blocked_at FROM federation_blocked_domains ORDER BY domain",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
.map(|rows| {
|
||||
rows.into_iter()
|
||||
.map(|r| BlockedDomain {
|
||||
domain: r.domain,
|
||||
reason: r.reason,
|
||||
blocked_at: r.blocked_at.to_rfc3339(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
async fn is_domain_blocked(&self, domain: &str) -> Result<bool> {
|
||||
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM federation_blocked_domains WHERE domain=$1")
|
||||
.bind(domain).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?;
|
||||
let n: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM federation_blocked_domains WHERE domain=$1")
|
||||
.bind(domain)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
@@ -300,7 +470,12 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
|
||||
async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM federation_blocked_actors WHERE local_user_id=$1 AND actor_url=$2")
|
||||
.bind(local_user_id).bind(actor_url).execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ())
|
||||
.bind(local_user_id)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>> {
|
||||
@@ -325,12 +500,29 @@ pub struct PostgresApUserRepository {
|
||||
}
|
||||
|
||||
impl PostgresApUserRepository {
|
||||
pub fn new(pool: PgPool, base_url: String) -> Self { Self { pool, base_url } }
|
||||
pub fn new(pool: PgPool, base_url: String) -> Self {
|
||||
Self { pool, base_url }
|
||||
}
|
||||
|
||||
fn row_to_ap_user(&self, id: uuid::Uuid, username: String, bio: Option<String>, avatar_url: Option<String>) -> ApUser {
|
||||
fn row_to_ap_user(
|
||||
&self,
|
||||
id: uuid::Uuid,
|
||||
username: String,
|
||||
bio: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
) -> ApUser {
|
||||
let profile_url = url::Url::parse(&format!("{}/users/{}", self.base_url, username)).ok();
|
||||
let avatar_url = avatar_url.and_then(|u| url::Url::parse(&u).ok());
|
||||
ApUser { id, username, bio, avatar_url, banner_url: None, also_known_as: None, profile_url, attachment: vec![] }
|
||||
ApUser {
|
||||
id,
|
||||
username,
|
||||
bio,
|
||||
avatar_url,
|
||||
banner_url: None,
|
||||
also_known_as: None,
|
||||
profile_url,
|
||||
attachment: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,25 +530,45 @@ impl PostgresApUserRepository {
|
||||
impl ApUserRepository for PostgresApUserRepository {
|
||||
async fn find_by_id(&self, id: uuid::Uuid) -> Result<Option<ApUser>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row { id: uuid::Uuid, username: String, bio: Option<String>, avatar_url: Option<String> }
|
||||
struct Row {
|
||||
id: uuid::Uuid,
|
||||
username: String,
|
||||
bio: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
}
|
||||
let row = sqlx::query_as::<_, Row>(
|
||||
"SELECT id,username,bio,avatar_url FROM users WHERE id=$1 AND local=true"
|
||||
).bind(id).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))?;
|
||||
"SELECT id,username,bio,avatar_url FROM users WHERE id=$1 AND local=true",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))?;
|
||||
Ok(row.map(|r| self.row_to_ap_user(r.id, r.username, r.bio, r.avatar_url)))
|
||||
}
|
||||
|
||||
async fn find_by_username(&self, username: &str) -> Result<Option<ApUser>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row { id: uuid::Uuid, username: String, bio: Option<String>, avatar_url: Option<String> }
|
||||
struct Row {
|
||||
id: uuid::Uuid,
|
||||
username: String,
|
||||
bio: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
}
|
||||
let row = sqlx::query_as::<_, Row>(
|
||||
"SELECT id,username,bio,avatar_url FROM users WHERE username=$1 AND local=true"
|
||||
).bind(username).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))?;
|
||||
"SELECT id,username,bio,avatar_url FROM users WHERE username=$1 AND local=true",
|
||||
)
|
||||
.bind(username)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))?;
|
||||
Ok(row.map(|r| self.row_to_ap_user(r.id, r.username, r.bio, r.avatar_url)))
|
||||
}
|
||||
|
||||
async fn count_users(&self) -> Result<usize> {
|
||||
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE local=true")
|
||||
.fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?;
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))?;
|
||||
Ok(n as usize)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use domain::models::thought::Visibility;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
@@ -11,10 +11,16 @@ use domain::{
|
||||
ports::SearchPort,
|
||||
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
|
||||
};
|
||||
use domain::models::thought::Visibility;
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub struct PgSearchRepository { pool: PgPool }
|
||||
impl PgSearchRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||
pub struct PgSearchRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
impl PgSearchRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct FeedRow {
|
||||
@@ -87,13 +93,28 @@ fn row_to_entry(r: FeedRow) -> FeedEntry {
|
||||
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, ap_id: r.u_ap_id, inbox_url: r.inbox_url,
|
||||
public_key: r.public_key, private_key: r.private_key,
|
||||
created_at: r.author_created_at, updated_at: r.author_updated_at,
|
||||
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,
|
||||
ap_id: r.u_ap_id,
|
||||
inbox_url: r.inbox_url,
|
||||
public_key: r.public_key,
|
||||
private_key: r.private_key,
|
||||
created_at: r.author_created_at,
|
||||
updated_at: r.author_updated_at,
|
||||
};
|
||||
FeedEntry { thought, author, like_count: r.like_count, boost_count: r.boost_count, reply_count: r.reply_count, liked_by_viewer: false, boosted_by_viewer: false }
|
||||
FeedEntry {
|
||||
thought,
|
||||
author,
|
||||
like_count: r.like_count,
|
||||
boost_count: r.boost_count,
|
||||
reply_count: r.reply_count,
|
||||
liked_by_viewer: false,
|
||||
boosted_by_viewer: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
@@ -123,11 +144,18 @@ impl From<UserRow> for User {
|
||||
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.local, ap_id: r.ap_id, inbox_url: r.inbox_url,
|
||||
public_key: r.public_key, private_key: r.private_key,
|
||||
created_at: r.created_at, updated_at: r.updated_at,
|
||||
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.local,
|
||||
ap_id: r.ap_id,
|
||||
inbox_url: r.inbox_url,
|
||||
public_key: r.public_key,
|
||||
private_key: r.private_key,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,7 +174,7 @@ impl SearchPort for PgSearchRepository {
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM thoughts t
|
||||
WHERE t.content % $1 AND t.visibility='public'"
|
||||
WHERE t.content % $1 AND t.visibility='public'",
|
||||
)
|
||||
.bind(query)
|
||||
.fetch_one(&self.pool)
|
||||
@@ -182,7 +210,7 @@ impl SearchPort for PgSearchRepository {
|
||||
) -> Result<Paginated<User>, DomainError> {
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM users u
|
||||
WHERE u.local=true AND (u.username % $1 OR u.display_name % $1)"
|
||||
WHERE u.local=true AND (u.username % $1 OR u.display_name % $1)",
|
||||
)
|
||||
.bind(query)
|
||||
.fetch_one(&self.pool)
|
||||
@@ -216,7 +244,10 @@ impl SearchPort for PgSearchRepository {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::{
|
||||
models::{thought::{Thought, Visibility}, user::User},
|
||||
models::{
|
||||
thought::{Thought, Visibility},
|
||||
user::User,
|
||||
},
|
||||
ports::{SearchPort, ThoughtRepository, UserRepository},
|
||||
value_objects::*,
|
||||
};
|
||||
@@ -233,9 +264,13 @@ mod tests {
|
||||
);
|
||||
urepo.save(&u).await.unwrap();
|
||||
let t = Thought::new_local(
|
||||
ThoughtId::new(), u.id.clone(),
|
||||
ThoughtId::new(),
|
||||
u.id.clone(),
|
||||
Content::new_local(content).unwrap(),
|
||||
None, Visibility::Public, None, false,
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
trepo.save(&t).await.unwrap();
|
||||
(u, t)
|
||||
@@ -246,7 +281,17 @@ mod tests {
|
||||
seed_thought(&pool, "alice", "hello world").await;
|
||||
seed_thought(&pool, "bob", "goodbye universe").await;
|
||||
let repo = PgSearchRepository::new(pool);
|
||||
let result = repo.search_thoughts("hello world", &PageParams { page: 1, per_page: 20 }, None).await.unwrap();
|
||||
let result = repo
|
||||
.search_thoughts(
|
||||
"hello world",
|
||||
&PageParams {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.total, 1);
|
||||
assert_eq!(result.items[0].thought.content.as_str(), "hello world");
|
||||
}
|
||||
@@ -255,19 +300,46 @@ mod tests {
|
||||
async fn search_users_finds_by_username(pool: sqlx::PgPool) {
|
||||
use postgres::user::PgUserRepository;
|
||||
let urepo = PgUserRepository::new(pool.clone());
|
||||
let alice = User::new_local(UserId::new(), Username::new("alice_search").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into()));
|
||||
let alice = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("alice_search").unwrap(),
|
||||
Email::new("alice@ex.com").unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
urepo.save(&alice).await.unwrap();
|
||||
let repo = PgSearchRepository::new(pool);
|
||||
let result = repo.search_users("alice", &PageParams { page: 1, per_page: 20 }).await.unwrap();
|
||||
let result = repo
|
||||
.search_users(
|
||||
"alice",
|
||||
&PageParams {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.items.is_empty());
|
||||
assert!(result.items.iter().any(|u| u.username.as_str() == "alice_search"));
|
||||
assert!(result
|
||||
.items
|
||||
.iter()
|
||||
.any(|u| u.username.as_str() == "alice_search"));
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../postgres/migrations")]
|
||||
async fn search_thoughts_returns_empty_for_no_match(pool: sqlx::PgPool) {
|
||||
seed_thought(&pool, "alice", "hello world").await;
|
||||
let repo = PgSearchRepository::new(pool);
|
||||
let result = repo.search_thoughts("zzzzzzzzz", &PageParams { page: 1, per_page: 20 }, None).await.unwrap();
|
||||
let result = repo
|
||||
.search_thoughts(
|
||||
"zzzzzzzzz",
|
||||
&PageParams {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.total, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,10 @@ impl PgActivityPubRepository {
|
||||
|
||||
#[async_trait]
|
||||
impl ActivityPubRepository for PgActivityPubRepository {
|
||||
async fn outbox_entries_for_actor(&self, user_id: &UserId) -> Result<Vec<OutboxEntry>, DomainError> {
|
||||
async fn outbox_entries_for_actor(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<OutboxEntry>, DomainError> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
id: uuid::Uuid,
|
||||
@@ -134,7 +137,10 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn find_remote_actor_id(&self, actor_ap_url: &Url) -> Result<Option<UserId>, DomainError> {
|
||||
async fn find_remote_actor_id(
|
||||
&self,
|
||||
actor_ap_url: &Url,
|
||||
) -> Result<Option<UserId>, DomainError> {
|
||||
sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM users WHERE ap_id=$1")
|
||||
.bind(actor_ap_url.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
@@ -148,7 +154,10 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
||||
return Ok(id);
|
||||
}
|
||||
let new_id = uuid::Uuid::new_v4();
|
||||
let handle = actor_ap_url.path().trim_start_matches('/').replace('/', "_");
|
||||
let handle = actor_ap_url
|
||||
.path()
|
||||
.trim_start_matches('/')
|
||||
.replace('/', "_");
|
||||
sqlx::query(
|
||||
"INSERT INTO users(id,username,email,password_hash,local,ap_id,created_at,updated_at)
|
||||
VALUES($1,$2,$3,'',false,$4,NOW(),NOW()) ON CONFLICT(ap_id) DO NOTHING",
|
||||
@@ -163,7 +172,11 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
||||
// Re-fetch to get whichever id won the race
|
||||
self.find_remote_actor_id(actor_ap_url)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::Internal("intern_remote_actor: insert succeeded but row not found".into()))
|
||||
.ok_or_else(|| {
|
||||
DomainError::Internal(
|
||||
"intern_remote_actor: insert succeeded but row not found".into(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn accept_note(
|
||||
@@ -195,13 +208,15 @@ impl ActivityPubRepository for PgActivityPubRepository {
|
||||
|
||||
async fn apply_note_update(&self, ap_id: &Url, new_content: &str) -> Result<(), DomainError> {
|
||||
let capped: String = new_content.chars().take(500).collect();
|
||||
sqlx::query("UPDATE thoughts SET content=$2,updated_at=NOW() WHERE ap_id=$1 AND local=false")
|
||||
.bind(ap_id.as_str())
|
||||
.bind(&capped)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
.map(|_| ())
|
||||
sqlx::query(
|
||||
"UPDATE thoughts SET content=$2,updated_at=NOW() WHERE ap_id=$1 AND local=false",
|
||||
)
|
||||
.bind(ap_id.as_str())
|
||||
.bind(&capped)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn retract_note(&self, ap_id: &Url) -> Result<(), DomainError> {
|
||||
@@ -253,9 +268,16 @@ mod tests {
|
||||
let actor_url = url::Url::parse("https://remote.example/users/bob").unwrap();
|
||||
let ap_id = url::Url::parse("https://remote.example/notes/1").unwrap();
|
||||
let author = repo.intern_remote_actor(&actor_url).await.unwrap();
|
||||
repo.accept_note(&ap_id, &author, "hello from remote", chrono::Utc::now(), false, None)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.accept_note(
|
||||
&ap_id,
|
||||
&author,
|
||||
"hello from remote",
|
||||
chrono::Utc::now(),
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.retract_note(&ap_id).await.unwrap();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,75 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::api_key::ApiKey,
|
||||
ports::ApiKeyRepository,
|
||||
value_objects::{ApiKeyId, UserId},
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use domain::{errors::DomainError, models::api_key::ApiKey, ports::ApiKeyRepository, value_objects::{ApiKeyId, UserId}};
|
||||
|
||||
pub struct PgApiKeyRepository { pool: PgPool }
|
||||
impl PgApiKeyRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||
pub struct PgApiKeyRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
impl PgApiKeyRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ApiKeyRepository for PgApiKeyRepository {
|
||||
async fn save(&self, k: &ApiKey) -> Result<(), DomainError> {
|
||||
sqlx::query("INSERT INTO api_keys(id,user_id,key_hash,name,created_at) VALUES($1,$2,$3,$4,$5)")
|
||||
.bind(k.id.as_uuid()).bind(k.user_id.as_uuid()).bind(&k.key_hash).bind(&k.name).bind(k.created_at)
|
||||
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
|
||||
sqlx::query(
|
||||
"INSERT INTO api_keys(id,user_id,key_hash,name,created_at) VALUES($1,$2,$3,$4,$5)",
|
||||
)
|
||||
.bind(k.id.as_uuid())
|
||||
.bind(k.user_id.as_uuid())
|
||||
.bind(&k.key_hash)
|
||||
.bind(&k.name)
|
||||
.bind(k.created_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKey>, DomainError> {
|
||||
#[derive(sqlx::FromRow)] struct Row { id: uuid::Uuid, user_id: uuid::Uuid, key_hash: String, name: String, created_at: DateTime<Utc> }
|
||||
sqlx::query_as::<_, Row>("SELECT id,user_id,key_hash,name,created_at FROM api_keys WHERE key_hash=$1")
|
||||
.bind(hash).fetch_optional(&self.pool).await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
.map(|o| o.map(|r| ApiKey { id: ApiKeyId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), key_hash: r.key_hash, name: r.name, created_at: r.created_at }))
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
id: uuid::Uuid,
|
||||
user_id: uuid::Uuid,
|
||||
key_hash: String,
|
||||
name: String,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT id,user_id,key_hash,name,created_at FROM api_keys WHERE key_hash=$1",
|
||||
)
|
||||
.bind(hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
.map(|o| {
|
||||
o.map(|r| ApiKey {
|
||||
id: ApiKeyId::from_uuid(r.id),
|
||||
user_id: UserId::from_uuid(r.user_id),
|
||||
key_hash: r.key_hash,
|
||||
name: r.name,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<ApiKey>, DomainError> {
|
||||
#[derive(sqlx::FromRow)] struct Row { id: uuid::Uuid, user_id: uuid::Uuid, key_hash: String, name: String, created_at: DateTime<Utc> }
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
id: uuid::Uuid,
|
||||
user_id: uuid::Uuid,
|
||||
key_hash: String,
|
||||
name: String,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
sqlx::query_as::<_, Row>("SELECT id,user_id,key_hash,name,created_at FROM api_keys WHERE user_id=$1 ORDER BY created_at DESC")
|
||||
.bind(user_id.as_uuid()).fetch_all(&self.pool).await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
@@ -32,30 +78,46 @@ impl ApiKeyRepository for PgApiKeyRepository {
|
||||
|
||||
async fn delete(&self, id: &ApiKeyId, user_id: &UserId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM api_keys WHERE id=$1 AND user_id=$2")
|
||||
.bind(id.as_uuid()).bind(user_id.as_uuid())
|
||||
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
|
||||
.bind(id.as_uuid())
|
||||
.bind(user_id.as_uuid())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use domain::{models::user::User, value_objects::*};
|
||||
use crate::user::PgUserRepository;
|
||||
use chrono::Utc;
|
||||
use domain::ports::UserRepository;
|
||||
use domain::{models::user::User, 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
|
||||
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")]
|
||||
async fn save_and_find_by_hash(pool: sqlx::PgPool) {
|
||||
let user = seed_user(&pool).await;
|
||||
let repo = PgApiKeyRepository::new(pool);
|
||||
let key = ApiKey { id: ApiKeyId::new(), user_id: user.id.clone(), key_hash: "abc123".into(), name: "test".into(), created_at: Utc::now() };
|
||||
let key = ApiKey {
|
||||
id: ApiKeyId::new(),
|
||||
user_id: user.id.clone(),
|
||||
key_hash: "abc123".into(),
|
||||
name: "test".into(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&key).await.unwrap();
|
||||
let found = repo.find_by_hash("abc123").await.unwrap().unwrap();
|
||||
assert_eq!(found.name, "test");
|
||||
@@ -65,7 +127,13 @@ mod tests {
|
||||
async fn delete_key(pool: sqlx::PgPool) {
|
||||
let user = seed_user(&pool).await;
|
||||
let repo = PgApiKeyRepository::new(pool);
|
||||
let key = ApiKey { id: ApiKeyId::new(), user_id: user.id.clone(), key_hash: "def456".into(), name: "key2".into(), created_at: Utc::now() };
|
||||
let key = ApiKey {
|
||||
id: ApiKeyId::new(),
|
||||
user_id: user.id.clone(),
|
||||
key_hash: "def456".into(),
|
||||
name: "key2".into(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&key).await.unwrap();
|
||||
repo.delete(&key.id, &user.id).await.unwrap();
|
||||
assert!(repo.find_by_hash("def456").await.unwrap().is_none());
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError, models::social::Block, ports::BlockRepository, value_objects::UserId,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use domain::{errors::DomainError, models::social::Block, ports::BlockRepository, value_objects::UserId};
|
||||
|
||||
pub struct PgBlockRepository { pool: PgPool }
|
||||
impl PgBlockRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||
pub struct PgBlockRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
impl PgBlockRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BlockRepository for PgBlockRepository {
|
||||
@@ -31,14 +39,13 @@ impl BlockRepository for PgBlockRepository {
|
||||
}
|
||||
|
||||
async fn exists(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<bool, DomainError> {
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM blocks WHERE blocker_id=$1 AND blocked_id=$2"
|
||||
)
|
||||
.bind(blocker_id.as_uuid())
|
||||
.bind(blocked_id.as_uuid())
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM blocks WHERE blocker_id=$1 AND blocked_id=$2")
|
||||
.bind(blocker_id.as_uuid())
|
||||
.bind(blocked_id.as_uuid())
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
}
|
||||
@@ -46,23 +53,33 @@ impl BlockRepository for PgBlockRepository {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use domain::{models::user::User, value_objects::*};
|
||||
use crate::user::PgUserRepository;
|
||||
use chrono::Utc;
|
||||
use domain::ports::UserRepository;
|
||||
use domain::{models::user::User, value_objects::*};
|
||||
|
||||
async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User {
|
||||
let repo = PgUserRepository::new(pool.clone());
|
||||
let u = User::new_local(UserId::new(), Username::new(username).unwrap(), Email::new(email).unwrap(), PasswordHash("h".into()));
|
||||
repo.save(&u).await.unwrap(); u
|
||||
let u = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new(username).unwrap(),
|
||||
Email::new(email).unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
repo.save(&u).await.unwrap();
|
||||
u
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn block_exists(pool: sqlx::PgPool) {
|
||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgBlockRepository::new(pool);
|
||||
let block = Block { blocker_id: alice.id.clone(), blocked_id: bob.id.clone(), created_at: Utc::now() };
|
||||
let block = Block {
|
||||
blocker_id: alice.id.clone(),
|
||||
blocked_id: bob.id.clone(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&block).await.unwrap();
|
||||
assert!(repo.exists(&alice.id, &bob.id).await.unwrap());
|
||||
assert!(!repo.exists(&bob.id, &alice.id).await.unwrap());
|
||||
@@ -71,9 +88,13 @@ mod tests {
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn unblock(pool: sqlx::PgPool) {
|
||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgBlockRepository::new(pool);
|
||||
let block = Block { blocker_id: alice.id.clone(), blocked_id: bob.id.clone(), created_at: Utc::now() };
|
||||
let block = Block {
|
||||
blocker_id: alice.id.clone(),
|
||||
blocked_id: bob.id.clone(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&block).await.unwrap();
|
||||
repo.delete(&alice.id, &bob.id).await.unwrap();
|
||||
assert!(!repo.exists(&alice.id, &bob.id).await.unwrap());
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::social::Boost,
|
||||
ports::BoostRepository,
|
||||
value_objects::{BoostId, ThoughtId, UserId},
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use domain::{errors::DomainError, models::social::Boost, ports::BoostRepository, value_objects::{BoostId, ThoughtId, UserId}};
|
||||
|
||||
pub struct PgBoostRepository { pool: PgPool }
|
||||
impl PgBoostRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||
pub struct PgBoostRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
impl PgBoostRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BoostRepository for PgBoostRepository {
|
||||
@@ -18,15 +29,30 @@ impl BoostRepository for PgBoostRepository {
|
||||
|
||||
async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> {
|
||||
let r = sqlx::query("DELETE FROM boosts WHERE user_id=$1 AND thought_id=$2")
|
||||
.bind(user_id.as_uuid()).bind(thought_id.as_uuid())
|
||||
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
if r.rows_affected() == 0 { return Err(DomainError::NotFound); }
|
||||
.bind(user_id.as_uuid())
|
||||
.bind(thought_id.as_uuid())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
if r.rows_affected() == 0 {
|
||||
return Err(DomainError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<Option<Boost>, DomainError> {
|
||||
async fn find(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
thought_id: &ThoughtId,
|
||||
) -> Result<Option<Boost>, DomainError> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row { id: uuid::Uuid, user_id: uuid::Uuid, thought_id: uuid::Uuid, ap_id: Option<String>, created_at: DateTime<Utc> }
|
||||
struct Row {
|
||||
id: uuid::Uuid,
|
||||
user_id: uuid::Uuid,
|
||||
thought_id: uuid::Uuid,
|
||||
ap_id: Option<String>,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
sqlx::query_as::<_, Row>("SELECT id,user_id,thought_id,ap_id,created_at FROM boosts WHERE user_id=$1 AND thought_id=$2")
|
||||
.bind(user_id.as_uuid()).bind(thought_id.as_uuid())
|
||||
.fetch_optional(&self.pool).await
|
||||
@@ -36,7 +62,9 @@ impl BoostRepository for PgBoostRepository {
|
||||
|
||||
async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result<i64, DomainError> {
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM boosts WHERE thought_id=$1")
|
||||
.bind(thought_id.as_uuid()).fetch_one(&self.pool).await
|
||||
.bind(thought_id.as_uuid())
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -44,17 +72,36 @@ impl BoostRepository for PgBoostRepository {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use domain::{models::{thought::{Thought, Visibility}, user::User}, value_objects::*};
|
||||
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
||||
use chrono::Utc;
|
||||
use domain::ports::{ThoughtRepository, UserRepository};
|
||||
use domain::{
|
||||
models::{
|
||||
thought::{Thought, Visibility},
|
||||
user::User,
|
||||
},
|
||||
value_objects::*,
|
||||
};
|
||||
|
||||
async fn seed(pool: &sqlx::PgPool) -> (User, Thought) {
|
||||
let urepo = PgUserRepository::new(pool.clone());
|
||||
let trepo = PgThoughtRepository::new(pool.clone());
|
||||
let u = User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into()));
|
||||
let u = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("alice").unwrap(),
|
||||
Email::new("alice@ex.com").unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
urepo.save(&u).await.unwrap();
|
||||
let t = Thought::new_local(ThoughtId::new(), u.id.clone(), Content::new_local("hi").unwrap(), None, Visibility::Public, None, false);
|
||||
let t = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
u.id.clone(),
|
||||
Content::new_local("hi").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
trepo.save(&t).await.unwrap();
|
||||
(u, t)
|
||||
}
|
||||
@@ -63,7 +110,13 @@ mod tests {
|
||||
async fn boost_and_count(pool: sqlx::PgPool) {
|
||||
let (user, thought) = seed(&pool).await;
|
||||
let repo = PgBoostRepository::new(pool);
|
||||
let boost = Boost { id: BoostId::new(), user_id: user.id.clone(), thought_id: thought.id.clone(), ap_id: None, created_at: Utc::now() };
|
||||
let boost = Boost {
|
||||
id: BoostId::new(),
|
||||
user_id: user.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
ap_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&boost).await.unwrap();
|
||||
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
|
||||
}
|
||||
@@ -72,7 +125,13 @@ mod tests {
|
||||
async fn unboost(pool: sqlx::PgPool) {
|
||||
let (user, thought) = seed(&pool).await;
|
||||
let repo = PgBoostRepository::new(pool);
|
||||
let boost = Boost { id: BoostId::new(), user_id: user.id.clone(), thought_id: thought.id.clone(), ap_id: None, created_at: Utc::now() };
|
||||
let boost = Boost {
|
||||
id: BoostId::new(),
|
||||
user_id: user.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
ap_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&boost).await.unwrap();
|
||||
repo.delete(&user.id, &thought.id).await.unwrap();
|
||||
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0);
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use domain::models::thought::Visibility;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{feed::{FeedEntry, PageParams, Paginated}, thought::Thought, user::User},
|
||||
models::{
|
||||
feed::{FeedEntry, PageParams, Paginated},
|
||||
thought::Thought,
|
||||
user::User,
|
||||
},
|
||||
ports::FeedRepository,
|
||||
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
|
||||
};
|
||||
use domain::models::thought::Visibility;
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub struct PgFeedRepository { pool: PgPool }
|
||||
impl PgFeedRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||
pub struct PgFeedRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
impl PgFeedRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct FeedRow {
|
||||
@@ -57,7 +67,8 @@ fn feed_select(viewer: Option<uuid::Uuid>) -> String {
|
||||
),
|
||||
None => "false AS liked_by_viewer, false AS boosted_by_viewer".to_string(),
|
||||
};
|
||||
format!("
|
||||
format!(
|
||||
"
|
||||
SELECT
|
||||
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,
|
||||
@@ -72,7 +83,8 @@ fn feed_select(viewer: Option<uuid::Uuid>) -> String {
|
||||
(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")
|
||||
FROM thoughts t JOIN users u ON u.id=t.user_id"
|
||||
)
|
||||
}
|
||||
|
||||
fn row_to_entry(r: FeedRow) -> FeedEntry {
|
||||
@@ -95,52 +107,105 @@ fn row_to_entry(r: FeedRow) -> FeedEntry {
|
||||
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, ap_id: r.u_ap_id, inbox_url: r.inbox_url,
|
||||
public_key: r.public_key, private_key: r.private_key,
|
||||
created_at: r.author_created_at, updated_at: r.author_updated_at,
|
||||
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,
|
||||
ap_id: r.u_ap_id,
|
||||
inbox_url: r.inbox_url,
|
||||
public_key: r.public_key,
|
||||
private_key: r.private_key,
|
||||
created_at: r.author_created_at,
|
||||
updated_at: r.author_updated_at,
|
||||
};
|
||||
FeedEntry { thought, author, like_count: r.like_count, boost_count: r.boost_count, reply_count: r.reply_count, liked_by_viewer: r.liked_by_viewer, boosted_by_viewer: r.boosted_by_viewer }
|
||||
FeedEntry {
|
||||
thought,
|
||||
author,
|
||||
like_count: r.like_count,
|
||||
boost_count: r.boost_count,
|
||||
reply_count: r.reply_count,
|
||||
liked_by_viewer: r.liked_by_viewer,
|
||||
boosted_by_viewer: r.boosted_by_viewer,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FeedRepository for PgFeedRepository {
|
||||
async fn home_feed(&self, following_ids: &[UserId], page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
async fn home_feed(
|
||||
&self,
|
||||
following_ids: &[UserId],
|
||||
page: &PageParams,
|
||||
viewer_id: Option<&UserId>,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
let ids: Vec<uuid::Uuid> = following_ids.iter().map(|id| id.as_uuid()).collect();
|
||||
let viewer = viewer_id.map(|v| v.as_uuid());
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id=ANY($1) AND t.visibility='public'"
|
||||
).bind(&ids).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id=ANY($1) AND t.visibility='public'",
|
||||
)
|
||||
.bind(&ids)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
let sel = feed_select(viewer);
|
||||
let sql = format!("{sel} WHERE t.user_id=ANY($1) AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $2 OFFSET $3");
|
||||
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||
.bind(&ids).bind(page.limit()).bind(page.offset())
|
||||
.fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
.bind(&ids)
|
||||
.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(row_to_entry).collect(), total, page: page.page, per_page: page.per_page })
|
||||
Ok(Paginated {
|
||||
items: rows.into_iter().map(row_to_entry).collect(),
|
||||
total,
|
||||
page: page.page,
|
||||
per_page: page.per_page,
|
||||
})
|
||||
}
|
||||
|
||||
async fn public_feed(&self, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
async fn public_feed(
|
||||
&self,
|
||||
page: &PageParams,
|
||||
viewer_id: Option<&UserId>,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
let viewer = viewer_id.map(|v| v.as_uuid());
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'"
|
||||
).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
"SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'",
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
let sel = feed_select(viewer);
|
||||
let sql = format!("{sel} WHERE t.local=true AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $1 OFFSET $2");
|
||||
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||
.bind(page.limit()).bind(page.offset())
|
||||
.fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
.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(row_to_entry).collect(), total, page: page.page, per_page: page.per_page })
|
||||
Ok(Paginated {
|
||||
items: rows.into_iter().map(row_to_entry).collect(),
|
||||
total,
|
||||
page: page.page,
|
||||
per_page: page.per_page,
|
||||
})
|
||||
}
|
||||
|
||||
async fn search(&self, query: &str, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
async fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
page: &PageParams,
|
||||
viewer_id: Option<&UserId>,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
let viewer = viewer_id.map(|v| v.as_uuid());
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'"
|
||||
"SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'",
|
||||
)
|
||||
.bind(query)
|
||||
.fetch_one(&self.pool)
|
||||
@@ -157,16 +222,26 @@ impl FeedRepository for PgFeedRepository {
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
Ok(Paginated { items: rows.into_iter().map(row_to_entry).collect(), total, page: page.page, per_page: page.per_page })
|
||||
Ok(Paginated {
|
||||
items: rows.into_iter().map(row_to_entry).collect(),
|
||||
total,
|
||||
page: page.page,
|
||||
per_page: page.per_page,
|
||||
})
|
||||
}
|
||||
|
||||
async fn tag_feed(&self, tag_name: &str, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
async fn tag_feed(
|
||||
&self,
|
||||
tag_name: &str,
|
||||
page: &PageParams,
|
||||
viewer_id: Option<&UserId>,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
let viewer = viewer_id.map(|v| v.as_uuid());
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM thoughts t
|
||||
JOIN thought_tags tt ON tt.thought_id = t.id
|
||||
JOIN tags tg ON tg.id = tt.tag_id
|
||||
WHERE tg.name = $1 AND t.visibility = 'public'"
|
||||
WHERE tg.name = $1 AND t.visibility = 'public'",
|
||||
)
|
||||
.bind(tag_name)
|
||||
.fetch_one(&self.pool)
|
||||
@@ -197,12 +272,17 @@ impl FeedRepository for PgFeedRepository {
|
||||
})
|
||||
}
|
||||
|
||||
async fn user_feed(&self, user_id: &UserId, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
async fn user_feed(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
page: &PageParams,
|
||||
viewer_id: Option<&UserId>,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
let viewer = viewer_id.map(|v| v.as_uuid());
|
||||
let uid = user_id.as_uuid();
|
||||
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id = $1 AND t.visibility = 'public'"
|
||||
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id = $1 AND t.visibility = 'public'",
|
||||
)
|
||||
.bind(uid)
|
||||
.fetch_one(&self.pool)
|
||||
@@ -231,15 +311,35 @@ impl FeedRepository for PgFeedRepository {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::{models::{thought::{Thought, Visibility}, user::User}, ports::{ThoughtRepository, UserRepository}, value_objects::*};
|
||||
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
||||
use domain::{
|
||||
models::{
|
||||
thought::{Thought, Visibility},
|
||||
user::User,
|
||||
},
|
||||
ports::{ThoughtRepository, UserRepository},
|
||||
value_objects::*,
|
||||
};
|
||||
|
||||
async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) {
|
||||
let urepo = PgUserRepository::new(pool.clone());
|
||||
let trepo = PgThoughtRepository::new(pool.clone());
|
||||
let u = User::new_local(UserId::new(), Username::new(username).unwrap(), Email::new(format!("{username}@ex.com")).unwrap(), PasswordHash("h".into()));
|
||||
let u = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new(username).unwrap(),
|
||||
Email::new(format!("{username}@ex.com")).unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
urepo.save(&u).await.unwrap();
|
||||
let t = Thought::new_local(ThoughtId::new(), u.id.clone(), Content::new_local(content).unwrap(), None, Visibility::Public, None, false);
|
||||
let t = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
u.id.clone(),
|
||||
Content::new_local(content).unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
trepo.save(&t).await.unwrap();
|
||||
(u, t)
|
||||
}
|
||||
@@ -248,7 +348,16 @@ mod tests {
|
||||
async fn public_feed_returns_local_thoughts(pool: sqlx::PgPool) {
|
||||
let (_, _) = seed(&pool, "alice", "hello").await;
|
||||
let repo = PgFeedRepository::new(pool);
|
||||
let result = repo.public_feed(&PageParams { page: 1, per_page: 20 }, None).await.unwrap();
|
||||
let result = repo
|
||||
.public_feed(
|
||||
&PageParams {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.total, 1);
|
||||
assert_eq!(result.items[0].thought.content.as_str(), "hello");
|
||||
}
|
||||
@@ -258,8 +367,21 @@ mod tests {
|
||||
let (_, _) = seed(&pool, "alice", "hello world").await;
|
||||
let (_, _) = seed(&pool, "bob", "goodbye world").await;
|
||||
let repo = PgFeedRepository::new(pool);
|
||||
let result = repo.search("hello world", &PageParams { page: 1, per_page: 20 }, None).await.unwrap();
|
||||
let result = repo
|
||||
.search(
|
||||
"hello world",
|
||||
&PageParams {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.total >= 1);
|
||||
assert!(result.items.iter().any(|e| e.thought.content.as_str() == "hello world"));
|
||||
assert!(result
|
||||
.items
|
||||
.iter()
|
||||
.any(|e| e.thought.content.as_str() == "hello world"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{feed::{PageParams, Paginated}, social::{Follow, FollowState}, user::User},
|
||||
models::{
|
||||
feed::{PageParams, Paginated},
|
||||
social::{Follow, FollowState},
|
||||
user::User,
|
||||
},
|
||||
ports::FollowRepository,
|
||||
value_objects::UserId,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub struct PgFollowRepository { pool: PgPool }
|
||||
impl PgFollowRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||
pub struct PgFollowRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
impl PgFollowRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FollowRepository for PgFollowRepository {
|
||||
@@ -37,13 +47,25 @@ impl FollowRepository for PgFollowRepository {
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
if r.rows_affected() == 0 { return Err(DomainError::NotFound); }
|
||||
if r.rows_affected() == 0 {
|
||||
return Err(DomainError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find(&self, follower_id: &UserId, following_id: &UserId) -> Result<Option<Follow>, DomainError> {
|
||||
async fn find(
|
||||
&self,
|
||||
follower_id: &UserId,
|
||||
following_id: &UserId,
|
||||
) -> Result<Option<Follow>, DomainError> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row { follower_id: uuid::Uuid, following_id: uuid::Uuid, state: String, ap_id: Option<String>, created_at: DateTime<Utc> }
|
||||
struct Row {
|
||||
follower_id: uuid::Uuid,
|
||||
following_id: uuid::Uuid,
|
||||
state: String,
|
||||
ap_id: Option<String>,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT follower_id,following_id,state,ap_id,created_at FROM follows WHERE follower_id=$1 AND following_id=$2"
|
||||
)
|
||||
@@ -61,7 +83,12 @@ impl FollowRepository for PgFollowRepository {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn update_state(&self, follower_id: &UserId, following_id: &UserId, state: &FollowState) -> Result<(), DomainError> {
|
||||
async fn update_state(
|
||||
&self,
|
||||
follower_id: &UserId,
|
||||
following_id: &UserId,
|
||||
state: &FollowState,
|
||||
) -> Result<(), DomainError> {
|
||||
sqlx::query("UPDATE follows SET state=$3 WHERE follower_id=$1 AND following_id=$2")
|
||||
.bind(follower_id.as_uuid())
|
||||
.bind(following_id.as_uuid())
|
||||
@@ -72,9 +99,13 @@ impl FollowRepository for PgFollowRepository {
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn list_followers(&self, user_id: &UserId, page: &PageParams) -> Result<Paginated<User>, DomainError> {
|
||||
async fn list_followers(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
page: &PageParams,
|
||||
) -> Result<Paginated<User>, DomainError> {
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM follows WHERE following_id=$1 AND state='accepted'"
|
||||
"SELECT COUNT(*) FROM follows WHERE following_id=$1 AND state='accepted'",
|
||||
)
|
||||
.bind(user_id.as_uuid())
|
||||
.fetch_one(&self.pool)
|
||||
@@ -102,9 +133,13 @@ impl FollowRepository for PgFollowRepository {
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_following(&self, user_id: &UserId, page: &PageParams) -> Result<Paginated<User>, DomainError> {
|
||||
async fn list_following(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
page: &PageParams,
|
||||
) -> Result<Paginated<User>, DomainError> {
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM follows WHERE follower_id=$1 AND state='accepted'"
|
||||
"SELECT COUNT(*) FROM follows WHERE follower_id=$1 AND state='accepted'",
|
||||
)
|
||||
.bind(user_id.as_uuid())
|
||||
.fetch_one(&self.pool)
|
||||
@@ -132,9 +167,12 @@ impl FollowRepository for PgFollowRepository {
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_accepted_following_ids(&self, user_id: &UserId) -> Result<Vec<UserId>, DomainError> {
|
||||
async fn get_accepted_following_ids(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<UserId>, DomainError> {
|
||||
let ids: Vec<uuid::Uuid> = sqlx::query_scalar(
|
||||
"SELECT following_id FROM follows WHERE follower_id=$1 AND state='accepted'"
|
||||
"SELECT following_id FROM follows WHERE follower_id=$1 AND state='accepted'",
|
||||
)
|
||||
.bind(user_id.as_uuid())
|
||||
.fetch_all(&self.pool)
|
||||
@@ -147,23 +185,35 @@ impl FollowRepository for PgFollowRepository {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use domain::{models::user::User, value_objects::*};
|
||||
use crate::user::PgUserRepository;
|
||||
use chrono::Utc;
|
||||
use domain::ports::UserRepository;
|
||||
use domain::{models::user::User, value_objects::*};
|
||||
|
||||
async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User {
|
||||
let repo = PgUserRepository::new(pool.clone());
|
||||
let u = User::new_local(UserId::new(), Username::new(username).unwrap(), Email::new(email).unwrap(), PasswordHash("h".into()));
|
||||
repo.save(&u).await.unwrap(); u
|
||||
let u = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new(username).unwrap(),
|
||||
Email::new(email).unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
repo.save(&u).await.unwrap();
|
||||
u
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn save_and_find_follow(pool: sqlx::PgPool) {
|
||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgFollowRepository::new(pool);
|
||||
let follow = Follow { follower_id: alice.id.clone(), following_id: bob.id.clone(), state: FollowState::Accepted, ap_id: None, created_at: Utc::now() };
|
||||
let follow = Follow {
|
||||
follower_id: alice.id.clone(),
|
||||
following_id: bob.id.clone(),
|
||||
state: FollowState::Accepted,
|
||||
ap_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&follow).await.unwrap();
|
||||
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
|
||||
assert_eq!(found.state, FollowState::Accepted);
|
||||
@@ -172,11 +222,19 @@ mod tests {
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn update_state(pool: sqlx::PgPool) {
|
||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgFollowRepository::new(pool);
|
||||
let follow = Follow { follower_id: alice.id.clone(), following_id: bob.id.clone(), state: FollowState::Pending, ap_id: None, created_at: Utc::now() };
|
||||
let follow = Follow {
|
||||
follower_id: alice.id.clone(),
|
||||
following_id: bob.id.clone(),
|
||||
state: FollowState::Pending,
|
||||
ap_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&follow).await.unwrap();
|
||||
repo.update_state(&alice.id, &bob.id, &FollowState::Accepted).await.unwrap();
|
||||
repo.update_state(&alice.id, &bob.id, &FollowState::Accepted)
|
||||
.await
|
||||
.unwrap();
|
||||
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
|
||||
assert_eq!(found.state, FollowState::Accepted);
|
||||
}
|
||||
@@ -184,9 +242,15 @@ mod tests {
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn get_accepted_following_ids(pool: sqlx::PgPool) {
|
||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgFollowRepository::new(pool);
|
||||
let follow = Follow { follower_id: alice.id.clone(), following_id: bob.id.clone(), state: FollowState::Accepted, ap_id: None, created_at: Utc::now() };
|
||||
let follow = Follow {
|
||||
follower_id: alice.id.clone(),
|
||||
following_id: bob.id.clone(),
|
||||
state: FollowState::Accepted,
|
||||
ap_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&follow).await.unwrap();
|
||||
let ids = repo.get_accepted_following_ids(&alice.id).await.unwrap();
|
||||
assert_eq!(ids, vec![bob.id]);
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::social::Like,
|
||||
ports::LikeRepository,
|
||||
value_objects::{LikeId, ThoughtId, UserId},
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use domain::{errors::DomainError, models::social::Like, ports::LikeRepository, value_objects::{LikeId, ThoughtId, UserId}};
|
||||
|
||||
pub struct PgLikeRepository { pool: PgPool }
|
||||
impl PgLikeRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||
pub struct PgLikeRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
impl PgLikeRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LikeRepository for PgLikeRepository {
|
||||
@@ -18,15 +29,30 @@ impl LikeRepository for PgLikeRepository {
|
||||
|
||||
async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> {
|
||||
let r = sqlx::query("DELETE FROM likes WHERE user_id=$1 AND thought_id=$2")
|
||||
.bind(user_id.as_uuid()).bind(thought_id.as_uuid())
|
||||
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
if r.rows_affected() == 0 { return Err(DomainError::NotFound); }
|
||||
.bind(user_id.as_uuid())
|
||||
.bind(thought_id.as_uuid())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
if r.rows_affected() == 0 {
|
||||
return Err(DomainError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<Option<Like>, DomainError> {
|
||||
async fn find(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
thought_id: &ThoughtId,
|
||||
) -> Result<Option<Like>, DomainError> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row { id: uuid::Uuid, user_id: uuid::Uuid, thought_id: uuid::Uuid, ap_id: Option<String>, created_at: DateTime<Utc> }
|
||||
struct Row {
|
||||
id: uuid::Uuid,
|
||||
user_id: uuid::Uuid,
|
||||
thought_id: uuid::Uuid,
|
||||
ap_id: Option<String>,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
sqlx::query_as::<_, Row>("SELECT id,user_id,thought_id,ap_id,created_at FROM likes WHERE user_id=$1 AND thought_id=$2")
|
||||
.bind(user_id.as_uuid()).bind(thought_id.as_uuid())
|
||||
.fetch_optional(&self.pool).await
|
||||
@@ -36,7 +62,9 @@ impl LikeRepository for PgLikeRepository {
|
||||
|
||||
async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result<i64, DomainError> {
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM likes WHERE thought_id=$1")
|
||||
.bind(thought_id.as_uuid()).fetch_one(&self.pool).await
|
||||
.bind(thought_id.as_uuid())
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -44,17 +72,36 @@ impl LikeRepository for PgLikeRepository {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use domain::{models::{thought::{Thought, Visibility}, user::User}, value_objects::*};
|
||||
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
||||
use chrono::Utc;
|
||||
use domain::ports::{ThoughtRepository, UserRepository};
|
||||
use domain::{
|
||||
models::{
|
||||
thought::{Thought, Visibility},
|
||||
user::User,
|
||||
},
|
||||
value_objects::*,
|
||||
};
|
||||
|
||||
async fn seed(pool: &sqlx::PgPool) -> (User, Thought) {
|
||||
let urepo = PgUserRepository::new(pool.clone());
|
||||
let trepo = PgThoughtRepository::new(pool.clone());
|
||||
let u = User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into()));
|
||||
let u = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("alice").unwrap(),
|
||||
Email::new("alice@ex.com").unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
urepo.save(&u).await.unwrap();
|
||||
let t = Thought::new_local(ThoughtId::new(), u.id.clone(), Content::new_local("hi").unwrap(), None, Visibility::Public, None, false);
|
||||
let t = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
u.id.clone(),
|
||||
Content::new_local("hi").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
trepo.save(&t).await.unwrap();
|
||||
(u, t)
|
||||
}
|
||||
@@ -63,7 +110,13 @@ mod tests {
|
||||
async fn like_and_count(pool: sqlx::PgPool) {
|
||||
let (user, thought) = seed(&pool).await;
|
||||
let repo = PgLikeRepository::new(pool);
|
||||
let like = Like { id: LikeId::new(), user_id: user.id.clone(), thought_id: thought.id.clone(), ap_id: None, created_at: Utc::now() };
|
||||
let like = Like {
|
||||
id: LikeId::new(),
|
||||
user_id: user.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
ap_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&like).await.unwrap();
|
||||
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
|
||||
}
|
||||
@@ -72,7 +125,13 @@ mod tests {
|
||||
async fn unlike(pool: sqlx::PgPool) {
|
||||
let (user, thought) = seed(&pool).await;
|
||||
let repo = PgLikeRepository::new(pool);
|
||||
let like = Like { id: LikeId::new(), user_id: user.id.clone(), thought_id: thought.id.clone(), ap_id: None, created_at: Utc::now() };
|
||||
let like = Like {
|
||||
id: LikeId::new(),
|
||||
user_id: user.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
ap_id: None,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&like).await.unwrap();
|
||||
repo.delete(&user.id, &thought.id).await.unwrap();
|
||||
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0);
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
feed::{PageParams, Paginated},
|
||||
notification::{Notification, NotificationType},
|
||||
},
|
||||
ports::NotificationRepository,
|
||||
value_objects::{NotificationId, ThoughtId, UserId},
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use domain::{errors::DomainError, models::{feed::{PageParams, Paginated}, notification::{Notification, NotificationType}}, ports::NotificationRepository, value_objects::{NotificationId, ThoughtId, UserId}};
|
||||
|
||||
pub struct PgNotificationRepository { pool: PgPool }
|
||||
impl PgNotificationRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||
pub struct PgNotificationRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
impl PgNotificationRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NotificationRepository for PgNotificationRepository {
|
||||
@@ -19,50 +33,91 @@ impl NotificationRepository for PgNotificationRepository {
|
||||
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
|
||||
}
|
||||
|
||||
async fn list_for_user(&self, user_id: &UserId, page: &PageParams) -> Result<Paginated<Notification>, DomainError> {
|
||||
async fn list_for_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
page: &PageParams,
|
||||
) -> Result<Paginated<Notification>, DomainError> {
|
||||
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM notifications WHERE user_id=$1")
|
||||
.bind(user_id.as_uuid()).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
.bind(user_id.as_uuid())
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row { 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> }
|
||||
struct Row {
|
||||
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())
|
||||
.fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
let items = rows.into_iter().map(|r| Notification {
|
||||
id: NotificationId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id),
|
||||
notification_type: NotificationType::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 { items, total, page: page.page, per_page: page.per_page })
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(|r| Notification {
|
||||
id: NotificationId::from_uuid(r.id),
|
||||
user_id: UserId::from_uuid(r.user_id),
|
||||
notification_type: NotificationType::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 {
|
||||
items,
|
||||
total,
|
||||
page: page.page,
|
||||
per_page: page.per_page,
|
||||
})
|
||||
}
|
||||
|
||||
async fn mark_read(&self, id: &NotificationId, user_id: &UserId) -> Result<(), DomainError> {
|
||||
sqlx::query("UPDATE notifications SET read=true WHERE id=$1 AND user_id=$2")
|
||||
.bind(id.as_uuid()).bind(user_id.as_uuid())
|
||||
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
|
||||
.bind(id.as_uuid())
|
||||
.bind(user_id.as_uuid())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn mark_all_read(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
sqlx::query("UPDATE notifications SET read=true WHERE user_id=$1")
|
||||
.bind(user_id.as_uuid())
|
||||
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use domain::{models::{notification::NotificationType, user::User}, value_objects::*};
|
||||
use crate::user::PgUserRepository;
|
||||
use chrono::Utc;
|
||||
use domain::ports::UserRepository;
|
||||
use domain::{
|
||||
models::{notification::NotificationType, user::User},
|
||||
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
|
||||
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")]
|
||||
@@ -70,9 +125,26 @@ mod tests {
|
||||
let user = seed_user(&pool).await;
|
||||
let repo = PgNotificationRepository::new(pool);
|
||||
use domain::models::feed::PageParams;
|
||||
let n = Notification { id: NotificationId::new(), user_id: user.id.clone(), notification_type: NotificationType::Like, from_user_id: None, thought_id: None, read: false, created_at: Utc::now() };
|
||||
let n = Notification {
|
||||
id: NotificationId::new(),
|
||||
user_id: user.id.clone(),
|
||||
notification_type: NotificationType::Like,
|
||||
from_user_id: None,
|
||||
thought_id: None,
|
||||
read: false,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&n).await.unwrap();
|
||||
let page = repo.list_for_user(&user.id, &PageParams { page: 1, per_page: 20 }).await.unwrap();
|
||||
let page = repo
|
||||
.list_for_user(
|
||||
&user.id,
|
||||
&PageParams {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(page.total, 1);
|
||||
assert!(!page.items[0].read);
|
||||
}
|
||||
@@ -82,10 +154,27 @@ mod tests {
|
||||
let user = seed_user(&pool).await;
|
||||
let repo = PgNotificationRepository::new(pool);
|
||||
use domain::models::feed::PageParams;
|
||||
let n = Notification { id: NotificationId::new(), user_id: user.id.clone(), notification_type: NotificationType::Follow, from_user_id: None, thought_id: None, read: false, created_at: Utc::now() };
|
||||
let n = Notification {
|
||||
id: NotificationId::new(),
|
||||
user_id: user.id.clone(),
|
||||
notification_type: NotificationType::Follow,
|
||||
from_user_id: None,
|
||||
thought_id: None,
|
||||
read: false,
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
repo.save(&n).await.unwrap();
|
||||
repo.mark_all_read(&user.id).await.unwrap();
|
||||
let page = repo.list_for_user(&user.id, &PageParams { page: 1, per_page: 20 }).await.unwrap();
|
||||
let page = repo
|
||||
.list_for_user(
|
||||
&user.id,
|
||||
&PageParams {
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(page.items[0].read);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use domain::{
|
||||
errors::DomainError, models::remote_actor::RemoteActor, ports::RemoteActorRepository,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use domain::{errors::DomainError, models::remote_actor::RemoteActor, ports::RemoteActorRepository};
|
||||
|
||||
pub struct PgRemoteActorRepository { pool: PgPool }
|
||||
impl PgRemoteActorRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||
pub struct PgRemoteActorRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
impl PgRemoteActorRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RemoteActorRepository for PgRemoteActorRepository {
|
||||
@@ -23,7 +31,15 @@ impl RemoteActorRepository for PgRemoteActorRepository {
|
||||
|
||||
async fn find_by_url(&self, url: &str) -> Result<Option<RemoteActor>, DomainError> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row { url: String, handle: String, display_name: Option<String>, inbox_url: String, shared_inbox_url: Option<String>, public_key: String, last_fetched_at: DateTime<Utc> }
|
||||
struct Row {
|
||||
url: String,
|
||||
handle: String,
|
||||
display_name: Option<String>,
|
||||
inbox_url: String,
|
||||
shared_inbox_url: Option<String>,
|
||||
public_key: String,
|
||||
last_fetched_at: DateTime<Utc>,
|
||||
}
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT url,handle,display_name,inbox_url,shared_inbox_url,public_key,last_fetched_at FROM remote_actors WHERE url=$1"
|
||||
).bind(url).fetch_optional(&self.pool).await
|
||||
|
||||
@@ -1,35 +1,81 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
feed::{PageParams, Paginated},
|
||||
tag::Tag,
|
||||
thought::Thought,
|
||||
},
|
||||
ports::TagRepository,
|
||||
value_objects::ThoughtId,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use domain::{errors::DomainError, models::{feed::{PageParams, Paginated}, tag::Tag, thought::Thought}, ports::TagRepository, value_objects::ThoughtId};
|
||||
|
||||
pub struct PgTagRepository { pool: PgPool }
|
||||
impl PgTagRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||
pub struct PgTagRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
impl PgTagRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TagRepository for PgTagRepository {
|
||||
async fn find_or_create(&self, name: &str) -> Result<Tag, DomainError> {
|
||||
let name = name.to_lowercase();
|
||||
sqlx::query("INSERT INTO tags(name) VALUES($1) ON CONFLICT(name) DO NOTHING")
|
||||
.bind(&name).execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
#[derive(sqlx::FromRow)] struct Row { id: i32, name: String }
|
||||
let row = sqlx::query_as::<_, Row>("SELECT id,name FROM tags WHERE name=$1").bind(&name)
|
||||
.fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
Ok(Tag { id: row.id, name: row.name })
|
||||
.bind(&name)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
id: i32,
|
||||
name: String,
|
||||
}
|
||||
let row = sqlx::query_as::<_, Row>("SELECT id,name FROM tags WHERE name=$1")
|
||||
.bind(&name)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
Ok(Tag {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
})
|
||||
}
|
||||
|
||||
async fn attach_to_thought(&self, thought_id: &ThoughtId, tag_id: i32) -> Result<(), DomainError> {
|
||||
sqlx::query("INSERT INTO thought_tags(thought_id,tag_id) VALUES($1,$2) ON CONFLICT DO NOTHING")
|
||||
.bind(thought_id.as_uuid()).bind(tag_id)
|
||||
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
|
||||
async fn attach_to_thought(
|
||||
&self,
|
||||
thought_id: &ThoughtId,
|
||||
tag_id: i32,
|
||||
) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO thought_tags(thought_id,tag_id) VALUES($1,$2) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(thought_id.as_uuid())
|
||||
.bind(tag_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn detach_from_thought(&self, thought_id: &ThoughtId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM thought_tags WHERE thought_id=$1").bind(thought_id.as_uuid())
|
||||
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
|
||||
sqlx::query("DELETE FROM thought_tags WHERE thought_id=$1")
|
||||
.bind(thought_id.as_uuid())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn list_for_thought(&self, thought_id: &ThoughtId) -> Result<Vec<Tag>, DomainError> {
|
||||
#[derive(sqlx::FromRow)] struct Row { id: i32, name: String }
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
id: i32,
|
||||
name: String,
|
||||
}
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT t.id,t.name FROM tags t JOIN thought_tags tt ON tt.tag_id=t.id WHERE tt.thought_id=$1"
|
||||
).bind(thought_id.as_uuid()).fetch_all(&self.pool).await
|
||||
@@ -37,10 +83,18 @@ impl TagRepository for PgTagRepository {
|
||||
.map(|rows| rows.into_iter().map(|r| Tag { id: r.id, name: r.name }).collect())
|
||||
}
|
||||
|
||||
async fn list_thoughts_by_tag(&self, tag_name: &str, page: &PageParams) -> Result<Paginated<Thought>, DomainError> {
|
||||
async fn list_thoughts_by_tag(
|
||||
&self,
|
||||
tag_name: &str,
|
||||
page: &PageParams,
|
||||
) -> Result<Paginated<Thought>, DomainError> {
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM thought_tags tt JOIN tags t ON t.id=tt.tag_id WHERE t.name=$1"
|
||||
).bind(tag_name).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
"SELECT COUNT(*) FROM thought_tags tt JOIN tags t ON t.id=tt.tag_id WHERE t.name=$1",
|
||||
)
|
||||
.bind(tag_name)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
let rows = sqlx::query_as::<_, crate::thought::ThoughtRow>(
|
||||
"SELECT th.id,th.user_id,th.content,th.in_reply_to_id,th.in_reply_to_url,th.ap_id,th.visibility,th.content_warning,th.sensitive,th.local,th.created_at,th.updated_at
|
||||
@@ -49,7 +103,12 @@ impl TagRepository for PgTagRepository {
|
||||
).bind(tag_name).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(Thought::from).collect(), total, page: page.page, per_page: page.per_page })
|
||||
Ok(Paginated {
|
||||
items: rows.into_iter().map(Thought::from).collect(),
|
||||
total,
|
||||
page: page.page,
|
||||
per_page: page.per_page,
|
||||
})
|
||||
}
|
||||
|
||||
async fn popular_tags(&self, limit: usize) -> Result<Vec<(String, i64)>, DomainError> {
|
||||
@@ -59,7 +118,7 @@ impl TagRepository for PgTagRepository {
|
||||
JOIN thought_tags tt ON t.id = tt.tag_id
|
||||
GROUP BY t.id, t.name
|
||||
ORDER BY thought_count DESC
|
||||
LIMIT $1"
|
||||
LIMIT $1",
|
||||
)
|
||||
.bind(limit as i64)
|
||||
.fetch_all(&self.pool)
|
||||
@@ -71,9 +130,15 @@ impl TagRepository for PgTagRepository {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::{models::{thought::{Thought, Visibility}, user::User}, value_objects::*};
|
||||
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
||||
use domain::ports::{ThoughtRepository, UserRepository};
|
||||
use domain::{
|
||||
models::{
|
||||
thought::{Thought, Visibility},
|
||||
user::User,
|
||||
},
|
||||
value_objects::*,
|
||||
};
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn find_or_create_tag(pool: sqlx::PgPool) {
|
||||
@@ -88,9 +153,22 @@ mod tests {
|
||||
async fn attach_and_list(pool: sqlx::PgPool) {
|
||||
let urepo = PgUserRepository::new(pool.clone());
|
||||
let trepo = PgThoughtRepository::new(pool.clone());
|
||||
let u = User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into()));
|
||||
let u = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("alice").unwrap(),
|
||||
Email::new("alice@ex.com").unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
urepo.save(&u).await.unwrap();
|
||||
let t = Thought::new_local(ThoughtId::new(), u.id.clone(), Content::new_local("hi").unwrap(), None, Visibility::Public, None, false);
|
||||
let t = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
u.id.clone(),
|
||||
Content::new_local("hi").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
trepo.save(&t).await.unwrap();
|
||||
let repo = PgTagRepository::new(pool);
|
||||
let tag = repo.find_or_create("greetings").await.unwrap();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
@@ -10,9 +9,16 @@ use domain::{
|
||||
ports::ThoughtRepository,
|
||||
value_objects::{Content, ThoughtId, UserId},
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub struct PgThoughtRepository { pool: PgPool }
|
||||
impl PgThoughtRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||
pub struct PgThoughtRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
impl PgThoughtRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct ThoughtRow {
|
||||
@@ -93,7 +99,9 @@ impl ThoughtRepository for PgThoughtRepository {
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
if r.rows_affected() == 0 { return Err(DomainError::NotFound); }
|
||||
if r.rows_affected() == 0 {
|
||||
return Err(DomainError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -108,9 +116,9 @@ impl ThoughtRepository for PgThoughtRepository {
|
||||
}
|
||||
|
||||
async fn get_thread(&self, id: &ThoughtId) -> Result<Vec<Thought>, DomainError> {
|
||||
sqlx::query_as::<_, ThoughtRow>(
|
||||
&format!("{THOUGHT_SELECT} WHERE id=$1 OR in_reply_to_id=$1 ORDER BY created_at ASC")
|
||||
)
|
||||
sqlx::query_as::<_, ThoughtRow>(&format!(
|
||||
"{THOUGHT_SELECT} WHERE id=$1 OR in_reply_to_id=$1 ORDER BY created_at ASC"
|
||||
))
|
||||
.bind(id.as_uuid())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
@@ -118,19 +126,21 @@ impl ThoughtRepository for PgThoughtRepository {
|
||||
.map(|rows| rows.into_iter().map(Thought::from).collect())
|
||||
}
|
||||
|
||||
async fn list_by_user(&self, user_id: &UserId, page: &PageParams) -> Result<Paginated<Thought>, DomainError> {
|
||||
async fn list_by_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
page: &PageParams,
|
||||
) -> Result<Paginated<Thought>, DomainError> {
|
||||
let uid = user_id.as_uuid();
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM thoughts WHERE user_id = $1"
|
||||
)
|
||||
.bind(uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts WHERE user_id = $1")
|
||||
.bind(uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
let rows = sqlx::query_as::<_, ThoughtRow>(
|
||||
&format!("{THOUGHT_SELECT} WHERE user_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3")
|
||||
)
|
||||
let rows = sqlx::query_as::<_, ThoughtRow>(&format!(
|
||||
"{THOUGHT_SELECT} WHERE user_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3"
|
||||
))
|
||||
.bind(uid)
|
||||
.bind(page.limit())
|
||||
.bind(page.offset())
|
||||
@@ -150,9 +160,15 @@ impl ThoughtRepository for PgThoughtRepository {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::{models::{thought::{Thought, Visibility}, user::User}, value_objects::*};
|
||||
use crate::user::PgUserRepository;
|
||||
use domain::ports::UserRepository;
|
||||
use domain::{
|
||||
models::{
|
||||
thought::{Thought, Visibility},
|
||||
user::User,
|
||||
},
|
||||
value_objects::*,
|
||||
};
|
||||
|
||||
async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User {
|
||||
let repo = PgUserRepository::new(pool.clone());
|
||||
@@ -189,7 +205,15 @@ mod tests {
|
||||
async fn delete_thought(pool: sqlx::PgPool) {
|
||||
let user = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgThoughtRepository::new(pool);
|
||||
let t = Thought::new_local(ThoughtId::new(), user.id.clone(), Content::new_local("bye").unwrap(), None, Visibility::Public, None, false);
|
||||
let t = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
user.id.clone(),
|
||||
Content::new_local("bye").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
repo.save(&t).await.unwrap();
|
||||
repo.delete(&t.id, &user.id).await.unwrap();
|
||||
assert!(repo.find_by_id(&t.id).await.unwrap().is_none());
|
||||
@@ -200,7 +224,15 @@ mod tests {
|
||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgThoughtRepository::new(pool);
|
||||
let t = Thought::new_local(ThoughtId::new(), alice.id.clone(), Content::new_local("secret").unwrap(), None, Visibility::Public, None, false);
|
||||
let t = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
alice.id.clone(),
|
||||
Content::new_local("secret").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
repo.save(&t).await.unwrap();
|
||||
let err = repo.delete(&t.id, &bob.id).await.unwrap_err();
|
||||
assert!(matches!(err, DomainError::NotFound));
|
||||
@@ -210,8 +242,24 @@ mod tests {
|
||||
async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) {
|
||||
let user = seed_user(&pool, "charlie", "charlie@ex.com").await;
|
||||
let repo = PgThoughtRepository::new(pool);
|
||||
let root = Thought::new_local(ThoughtId::new(), user.id.clone(), Content::new_local("root").unwrap(), None, Visibility::Public, None, false);
|
||||
let reply = Thought::new_local(ThoughtId::new(), user.id.clone(), Content::new_local("reply").unwrap(), Some(root.id.clone()), Visibility::Public, None, false);
|
||||
let root = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
user.id.clone(),
|
||||
Content::new_local("root").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
let reply = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
user.id.clone(),
|
||||
Content::new_local("reply").unwrap(),
|
||||
Some(root.id.clone()),
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
repo.save(&root).await.unwrap();
|
||||
repo.save(&reply).await.unwrap();
|
||||
let thread = repo.get_thread(&root.id).await.unwrap();
|
||||
|
||||
@@ -1,34 +1,74 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{top_friend::TopFriend, user::User},
|
||||
ports::TopFriendRepository,
|
||||
value_objects::UserId,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use domain::{errors::DomainError, models::{top_friend::TopFriend, user::User}, ports::TopFriendRepository, value_objects::UserId};
|
||||
|
||||
pub struct PgTopFriendRepository { pool: PgPool }
|
||||
impl PgTopFriendRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||
pub struct PgTopFriendRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
impl PgTopFriendRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TopFriendRepository for PgTopFriendRepository {
|
||||
async fn set_top_friends(&self, user_id: &UserId, friends: Vec<(UserId, i16)>) -> Result<(), DomainError> {
|
||||
let mut tx = self.pool.begin().await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
async fn set_top_friends(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
friends: Vec<(UserId, i16)>,
|
||||
) -> Result<(), DomainError> {
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
sqlx::query("DELETE FROM top_friends WHERE user_id=$1")
|
||||
.bind(user_id.as_uuid()).execute(&mut *tx).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
.bind(user_id.as_uuid())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
for (friend_id, pos) in friends {
|
||||
sqlx::query("INSERT INTO top_friends(user_id,friend_id,position) VALUES($1,$2,$3)")
|
||||
.bind(user_id.as_uuid()).bind(friend_id.as_uuid()).bind(pos)
|
||||
.execute(&mut *tx).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
.bind(user_id.as_uuid())
|
||||
.bind(friend_id.as_uuid())
|
||||
.bind(pos)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
}
|
||||
tx.commit().await.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<(TopFriend, User)>, DomainError> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
tf_user_id: uuid::Uuid, friend_id: uuid::Uuid, position: i16,
|
||||
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>, local: bool,
|
||||
ap_id: Option<String>, inbox_url: Option<String>, public_key: Option<String>,
|
||||
tf_user_id: uuid::Uuid,
|
||||
friend_id: uuid::Uuid,
|
||||
position: i16,
|
||||
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>,
|
||||
local: bool,
|
||||
ap_id: Option<String>,
|
||||
inbox_url: Option<String>,
|
||||
public_key: Option<String>,
|
||||
private_key: Option<String>,
|
||||
created_at: chrono::DateTime<chrono::Utc>, updated_at: chrono::DateTime<chrono::Utc>,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
let rows = sqlx::query_as::<_, Row>(
|
||||
"SELECT tf.user_id AS tf_user_id, tf.friend_id, tf.position,
|
||||
@@ -36,44 +76,73 @@ impl TopFriendRepository for PgTopFriendRepository {
|
||||
u.avatar_url, u.header_url, u.custom_css, u.local, u.ap_id, u.inbox_url,
|
||||
u.public_key, u.private_key, u.created_at, u.updated_at
|
||||
FROM top_friends tf JOIN users u ON u.id=tf.friend_id
|
||||
WHERE tf.user_id=$1 ORDER BY tf.position"
|
||||
).bind(user_id.as_uuid()).fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
WHERE tf.user_id=$1 ORDER BY tf.position",
|
||||
)
|
||||
.bind(user_id.as_uuid())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
Ok(rows.into_iter().map(|r| {
|
||||
use domain::value_objects::{Email, PasswordHash, Username};
|
||||
let tf = TopFriend { user_id: UserId::from_uuid(r.tf_user_id), friend_id: UserId::from_uuid(r.friend_id), position: r.position };
|
||||
let u = User {
|
||||
id: UserId::from_uuid(r.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.local,
|
||||
ap_id: r.ap_id, inbox_url: r.inbox_url, public_key: r.public_key,
|
||||
private_key: r.private_key, created_at: r.created_at, updated_at: r.updated_at,
|
||||
};
|
||||
(tf, u)
|
||||
}).collect())
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
use domain::value_objects::{Email, PasswordHash, Username};
|
||||
let tf = TopFriend {
|
||||
user_id: UserId::from_uuid(r.tf_user_id),
|
||||
friend_id: UserId::from_uuid(r.friend_id),
|
||||
position: r.position,
|
||||
};
|
||||
let u = User {
|
||||
id: UserId::from_uuid(r.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.local,
|
||||
ap_id: r.ap_id,
|
||||
inbox_url: r.inbox_url,
|
||||
public_key: r.public_key,
|
||||
private_key: r.private_key,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
};
|
||||
(tf, u)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::{models::user::User, value_objects::*};
|
||||
use crate::user::PgUserRepository;
|
||||
use domain::ports::UserRepository;
|
||||
use domain::{models::user::User, value_objects::*};
|
||||
|
||||
async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User {
|
||||
let repo = PgUserRepository::new(pool.clone());
|
||||
let u = User::new_local(UserId::new(), Username::new(username).unwrap(), Email::new(email).unwrap(), PasswordHash("h".into()));
|
||||
repo.save(&u).await.unwrap(); u
|
||||
let u = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new(username).unwrap(),
|
||||
Email::new(email).unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
repo.save(&u).await.unwrap();
|
||||
u
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn set_and_list_top_friends(pool: sqlx::PgPool) {
|
||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgTopFriendRepository::new(pool);
|
||||
repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)]).await.unwrap();
|
||||
repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)])
|
||||
.await
|
||||
.unwrap();
|
||||
let friends = repo.list_for_user(&alice.id).await.unwrap();
|
||||
assert_eq!(friends.len(), 1);
|
||||
assert_eq!(friends[0].0.position, 1);
|
||||
@@ -83,11 +152,15 @@ mod tests {
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn replace_top_friends(pool: sqlx::PgPool) {
|
||||
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let carol = seed_user(&pool, "carol", "carol@ex.com").await;
|
||||
let repo = PgTopFriendRepository::new(pool);
|
||||
repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)]).await.unwrap();
|
||||
repo.set_top_friends(&alice.id, vec![(carol.id.clone(), 1)]).await.unwrap();
|
||||
repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)])
|
||||
.await
|
||||
.unwrap();
|
||||
repo.set_top_friends(&alice.id, vec![(carol.id.clone(), 1)])
|
||||
.await
|
||||
.unwrap();
|
||||
let friends = repo.list_for_user(&alice.id).await.unwrap();
|
||||
assert_eq!(friends.len(), 1);
|
||||
assert_eq!(friends[0].1.username.as_str(), "carol");
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{feed::UserSummary, user::User},
|
||||
ports::UserRepository,
|
||||
value_objects::{Email, PasswordHash, UserId, Username},
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub struct PgUserRepository { pool: PgPool }
|
||||
impl PgUserRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||
pub struct PgUserRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
impl PgUserRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct UserRow {
|
||||
@@ -120,7 +126,15 @@ impl UserRepository for PgUserRepository {
|
||||
.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> {
|
||||
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"
|
||||
)
|
||||
@@ -159,22 +173,25 @@ impl UserRepository for PgUserRepository {
|
||||
LEFT JOIN follows f2 ON f2.follower_id=u.id AND f2.state='accepted'
|
||||
WHERE u.local=true
|
||||
GROUP BY u.id
|
||||
ORDER BY u.username"
|
||||
ORDER BY u.username",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
Ok(rows.into_iter().map(|r| UserSummary {
|
||||
id: UserId::from_uuid(r.id),
|
||||
username: r.username,
|
||||
display_name: r.display_name,
|
||||
avatar_url: r.avatar_url,
|
||||
bio: r.bio,
|
||||
thought_count: r.thought_count,
|
||||
follower_count: r.follower_count,
|
||||
following_count: r.following_count,
|
||||
}).collect())
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| UserSummary {
|
||||
id: UserId::from_uuid(r.id),
|
||||
username: r.username,
|
||||
display_name: r.display_name,
|
||||
avatar_url: r.avatar_url,
|
||||
bio: r.bio,
|
||||
thought_count: r.thought_count,
|
||||
follower_count: r.follower_count,
|
||||
following_count: r.following_count,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count(&self) -> Result<i64, DomainError> {
|
||||
@@ -208,7 +225,10 @@ mod tests {
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn find_by_username_returns_none_when_missing(pool: sqlx::PgPool) {
|
||||
let repo = PgUserRepository::new(pool);
|
||||
let result = repo.find_by_username(&Username::new("ghost").unwrap()).await.unwrap();
|
||||
let result = repo
|
||||
.find_by_username(&Username::new("ghost").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
@@ -222,7 +242,10 @@ mod tests {
|
||||
PasswordHash("hash".into()),
|
||||
);
|
||||
repo.save(&user).await.unwrap();
|
||||
let found = repo.find_by_email(&Email::new("bob@ex.com").unwrap()).await.unwrap();
|
||||
let found = repo
|
||||
.find_by_email(&Email::new("bob@ex.com").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(found.is_some());
|
||||
}
|
||||
|
||||
@@ -236,7 +259,16 @@ mod tests {
|
||||
PasswordHash("hash".into()),
|
||||
);
|
||||
repo.save(&user).await.unwrap();
|
||||
repo.update_profile(&user.id, Some("Charlie".into()), Some("bio".into()), None, None, None).await.unwrap();
|
||||
repo.update_profile(
|
||||
&user.id,
|
||||
Some("Charlie".into()),
|
||||
Some("bio".into()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
|
||||
assert_eq!(found.display_name.as_deref(), Some("Charlie"));
|
||||
assert_eq!(found.bio.as_deref(), Some("bio"));
|
||||
|
||||
Reference in New Issue
Block a user