feat: implement merge readiness plan to close gaps between v2 and v1
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 5m8s
test / unit (pull_request) Successful in 16m18s
test / integration (pull_request) Failing after 16m59s
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 5m8s
test / unit (pull_request) Successful in 16m18s
test / integration (pull_request) Failing after 16m59s
- Task 1: Fix feed response hydration by adding `to_thought_response` helper and updating feed handlers to return full `ThoughtResponse`. - Task 2: Wire follower/following REST routes for user feeds. - Task 3: Add user listing and count endpoints, including `GET /users` and `GET /users/count`. - Task 4: Implement popular tags feature with `GET /tags/popular`. - Task 5: Enhance configuration with HOST, CORS_ORIGINS, and optional rate limiting using tower-governor.
This commit is contained in:
@@ -1,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");
|
||||
|
||||
Reference in New Issue
Block a user