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