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