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

- 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:
2026-05-14 16:28:18 +02:00
parent e6f4a6256f
commit 004bfb427b
30 changed files with 8716 additions and 808 deletions

View File

@@ -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(&note)?))
}).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(&note)?))
})
.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(&note)?, 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(&note)?, 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,
&note.content,
note.published,
note.sensitive,
note.summary,
).await.map_err(|e| anyhow!("{e}"))
self.repo
.accept_note(
ap_id,
&author_id,
&note.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, &note.content).await
self.repo
.apply_note_update(ap_id, &note.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}"))
}
}

View File

@@ -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(&note).unwrap();

View File

@@ -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/"));
}
}

View File

@@ -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())

View File

@@ -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"
);
}
}

View File

@@ -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) }))

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}

View File

@@ -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());

View File

@@ -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());

View File

@@ -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);

View File

@@ -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"));
}
}

View File

@@ -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]);

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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();

View File

@@ -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();

View File

@@ -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");

View File

@@ -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"));