Compare commits
16 Commits
65ec64a4d9
...
2754e3e820
| Author | SHA1 | Date | |
|---|---|---|---|
| 2754e3e820 | |||
| 4b1e7565ac | |||
| 5618da7d37 | |||
| e9f7851400 | |||
| d701a40e61 | |||
| eea1d3fe24 | |||
| 90866aea58 | |||
| 2d2b5dde6a | |||
| bf5fd618cb | |||
| ae8a3dc6ed | |||
| 0222a168db | |||
| d3223923e4 | |||
| f4db518167 | |||
| 8628acfb77 | |||
| 1ab3766ce8 | |||
| ca35e8e774 |
@@ -15,3 +15,5 @@ jsonwebtoken = "9"
|
||||
argon2 = "0.5"
|
||||
bcrypt = "0.15"
|
||||
rand = "0.8"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
89
crates/adapters/auth/src/api_key_service.rs
Normal file
89
crates/adapters/auth/src/api_key_service.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{ApiKeyRepository, ApiKeyService},
|
||||
value_objects::UserId,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct ApiKeyServiceImpl {
|
||||
repo: Arc<dyn ApiKeyRepository>,
|
||||
}
|
||||
|
||||
impl ApiKeyServiceImpl {
|
||||
pub fn new(repo: Arc<dyn ApiKeyRepository>) -> Self {
|
||||
Self { repo }
|
||||
}
|
||||
|
||||
fn hash(raw: &str) -> String {
|
||||
hex::encode(Sha256::digest(raw.as_bytes()))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ApiKeyService for ApiKeyServiceImpl {
|
||||
async fn validate_key(&self, raw_key: &str) -> Result<Option<UserId>, DomainError> {
|
||||
let hash = Self::hash(raw_key);
|
||||
Ok(self.repo.find_by_hash(&hash).await?.map(|k| k.user_id))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::api_key::ApiKey,
|
||||
ports::ApiKeyRepository,
|
||||
value_objects::{ApiKeyId, UserId},
|
||||
};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
struct FakeApiKeyRepo(Mutex<Vec<ApiKey>>);
|
||||
|
||||
#[async_trait]
|
||||
impl ApiKeyRepository for FakeApiKeyRepo {
|
||||
async fn save(&self, key: &ApiKey) -> Result<(), DomainError> {
|
||||
self.0.lock().unwrap().push(key.clone());
|
||||
Ok(())
|
||||
}
|
||||
async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKey>, DomainError> {
|
||||
Ok(self.0.lock().unwrap().iter().find(|k| k.key_hash == hash).cloned())
|
||||
}
|
||||
async fn list_for_user(&self, _uid: &UserId) -> Result<Vec<ApiKey>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn delete(&self, _id: &ApiKeyId, _uid: &UserId) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_known_key_returns_user_id() {
|
||||
let uid = UserId::new();
|
||||
let raw = "super-secret-key";
|
||||
let hash = ApiKeyServiceImpl::hash(raw);
|
||||
let key = ApiKey {
|
||||
id: ApiKeyId::new(),
|
||||
user_id: uid.clone(),
|
||||
key_hash: hash,
|
||||
name: "test".into(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
let repo = Arc::new(FakeApiKeyRepo(Mutex::new(vec![key])));
|
||||
let svc = ApiKeyServiceImpl::new(repo);
|
||||
let result = svc.validate_key(raw).await.unwrap();
|
||||
assert_eq!(result.unwrap().as_uuid(), uid.as_uuid());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_unknown_key_returns_none() {
|
||||
let repo = Arc::new(FakeApiKeyRepo(Mutex::new(vec![])));
|
||||
let svc = ApiKeyServiceImpl::new(repo);
|
||||
let result = svc.validate_key("unknown-key").await.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
mod api_key_service;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{Duration, Utc};
|
||||
use domain::{
|
||||
@@ -8,6 +10,8 @@ use domain::{
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use api_key_service::ApiKeyServiceImpl;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Claims {
|
||||
sub: String,
|
||||
|
||||
83
crates/adapters/postgres/src/engagement.rs
Normal file
83
crates/adapters/postgres/src/engagement.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use crate::db_error::IntoDbResult;
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::feed::{EngagementStats, ViewerContext},
|
||||
ports::EngagementRepository,
|
||||
value_objects::{ThoughtId, UserId},
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub struct PgEngagementRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgEngagementRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EngagementRepository for PgEngagementRepository {
|
||||
async fn get_for_thoughts(
|
||||
&self,
|
||||
thought_ids: &[ThoughtId],
|
||||
viewer_id: Option<&UserId>,
|
||||
) -> Result<HashMap<ThoughtId, (EngagementStats, Option<ViewerContext>)>, DomainError> {
|
||||
if thought_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
thought_id: uuid::Uuid,
|
||||
like_count: i64,
|
||||
boost_count: i64,
|
||||
reply_count: i64,
|
||||
liked_by_viewer: bool,
|
||||
boosted_by_viewer: bool,
|
||||
}
|
||||
|
||||
let ids: Vec<uuid::Uuid> = thought_ids.iter().map(|t| t.as_uuid()).collect();
|
||||
let viewer_uuid: Option<uuid::Uuid> = viewer_id.map(|v| v.as_uuid());
|
||||
|
||||
let rows = sqlx::query_as::<_, Row>(
|
||||
"SELECT
|
||||
t.id AS thought_id,
|
||||
COUNT(DISTINCT l.user_id) AS like_count,
|
||||
COUNT(DISTINCT b.user_id) AS boost_count,
|
||||
COUNT(DISTINCT r.id) AS reply_count,
|
||||
COALESCE(BOOL_OR(l.user_id = $2), false) AS liked_by_viewer,
|
||||
COALESCE(BOOL_OR(b.user_id = $2), false) AS boosted_by_viewer
|
||||
FROM thoughts t
|
||||
LEFT JOIN likes l ON l.thought_id = t.id
|
||||
LEFT JOIN boosts b ON b.thought_id = t.id
|
||||
LEFT JOIN thoughts r ON r.in_reply_to_id = t.id
|
||||
WHERE t.id = ANY($1)
|
||||
GROUP BY t.id",
|
||||
)
|
||||
.bind(&ids[..])
|
||||
.bind(viewer_uuid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
|
||||
let mut result = HashMap::new();
|
||||
for row in rows {
|
||||
let tid = ThoughtId::from_uuid(row.thought_id);
|
||||
let stats = EngagementStats {
|
||||
like_count: row.like_count,
|
||||
boost_count: row.boost_count,
|
||||
reply_count: row.reply_count,
|
||||
};
|
||||
let viewer = viewer_id.map(|_| ViewerContext {
|
||||
liked: row.liked_by_viewer,
|
||||
boosted: row.boosted_by_viewer,
|
||||
});
|
||||
result.insert(tid, (stats, viewer));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod activitypub;
|
||||
pub mod engagement;
|
||||
pub mod api_key;
|
||||
pub mod block;
|
||||
pub mod boost;
|
||||
|
||||
@@ -3,11 +3,13 @@ use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{feed::UserSummary, user::User},
|
||||
models::feed::{PageParams, Paginated, UserSummary},
|
||||
models::user::User,
|
||||
ports::{UserReader, UserWriter},
|
||||
value_objects::{Email, PasswordHash, UserId, Username},
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub struct PgUserRepository {
|
||||
pool: PgPool,
|
||||
@@ -136,6 +138,76 @@ impl UserReader for PgUserRepository {
|
||||
.await
|
||||
.into_domain()
|
||||
}
|
||||
|
||||
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
id: uuid::Uuid,
|
||||
username: String,
|
||||
display_name: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
bio: Option<String>,
|
||||
thought_count: i64,
|
||||
follower_count: i64,
|
||||
following_count: i64,
|
||||
total: i64,
|
||||
}
|
||||
let rows = sqlx::query_as::<_, Row>(
|
||||
"SELECT u.id, u.username, u.display_name, u.avatar_url, u.bio,
|
||||
COUNT(DISTINCT t.id) AS thought_count,
|
||||
COUNT(DISTINCT f1.follower_id) AS follower_count,
|
||||
COUNT(DISTINCT f2.following_id) AS following_count,
|
||||
COUNT(*) OVER() AS total
|
||||
FROM users u
|
||||
LEFT JOIN thoughts t ON t.user_id=u.id AND t.local=true
|
||||
LEFT JOIN follows f1 ON f1.following_id=u.id AND f1.state='accepted'
|
||||
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
|
||||
LIMIT $1 OFFSET $2",
|
||||
)
|
||||
.bind(page.limit())
|
||||
.bind(page.offset())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
|
||||
let total = rows.first().map(|r| r.total).unwrap_or(0);
|
||||
let items = 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(Paginated { items, total, page: page.page, per_page: page.per_page })
|
||||
}
|
||||
|
||||
async fn find_by_ids(&self, ids: &[UserId]) -> Result<HashMap<UserId, User>, DomainError> {
|
||||
if ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let uuids: Vec<uuid::Uuid> = ids.iter().map(|id| id.as_uuid()).collect();
|
||||
let rows = sqlx::query_as::<_, UserRow>(
|
||||
&format!("{USER_SELECT} WHERE id = ANY($1)")
|
||||
)
|
||||
.bind(&uuids[..])
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
|
||||
Ok(rows.into_iter().map(|r| {
|
||||
let user = User::from(r);
|
||||
(user.id.clone(), user)
|
||||
}).collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -166,7 +238,18 @@ impl UserWriter for PgUserRepository {
|
||||
.bind(user.updated_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
.map_err(|e| {
|
||||
if let sqlx::Error::Database(ref db) = e {
|
||||
if db.code().as_deref() == Some("23505") {
|
||||
return match db.constraint().unwrap_or("") {
|
||||
"users_username_key" => DomainError::UniqueViolation { field: "username" },
|
||||
"users_email_key" => DomainError::UniqueViolation { field: "email" },
|
||||
_ => DomainError::UniqueViolation { field: "unknown" },
|
||||
};
|
||||
}
|
||||
}
|
||||
DomainError::Internal(e.to_string())
|
||||
})
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
tracing = { workspace = true }
|
||||
url = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
|
||||
@@ -38,11 +38,15 @@ pub async fn register(
|
||||
.save(&user)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
DomainError::Conflict(c) => match c.as_str() {
|
||||
"users_username_key" => DomainError::Conflict("username taken".into()),
|
||||
"users_email_key" => DomainError::Conflict("email taken".into()),
|
||||
_ => DomainError::Conflict("already exists".into()),
|
||||
},
|
||||
DomainError::UniqueViolation { field: "username" } => {
|
||||
DomainError::Conflict("username taken".into())
|
||||
}
|
||||
DomainError::UniqueViolation { field: "email" } => {
|
||||
DomainError::Conflict("email taken".into())
|
||||
}
|
||||
DomainError::UniqueViolation { .. } => {
|
||||
DomainError::Conflict("already exists".into())
|
||||
}
|
||||
other => other,
|
||||
})?;
|
||||
events
|
||||
@@ -100,7 +104,7 @@ mod tests {
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{feed::UserSummary, user::User},
|
||||
models::{feed::{PageParams, Paginated, UserSummary}, user::User},
|
||||
ports::{AuthService, GeneratedToken, PasswordHasher, UserReader, UserWriter},
|
||||
testing::{NoOpEventPublisher, TestStore},
|
||||
value_objects::{Email, PasswordHash, UserId, Username},
|
||||
@@ -131,12 +135,18 @@ mod tests {
|
||||
async fn count(&self) -> Result<i64, DomainError> {
|
||||
self.0.count().await
|
||||
}
|
||||
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError> {
|
||||
self.0.list_paginated(page).await
|
||||
}
|
||||
async fn find_by_ids(&self, ids: &[UserId]) -> Result<std::collections::HashMap<UserId, User>, DomainError> {
|
||||
self.0.find_by_ids(ids).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserWriter for ConflictOnSaveStore {
|
||||
async fn save(&self, _user: &User) -> Result<(), DomainError> {
|
||||
Err(DomainError::Conflict("users_username_key".into()))
|
||||
Err(DomainError::UniqueViolation { field: "username" })
|
||||
}
|
||||
async fn update_profile(
|
||||
&self,
|
||||
@@ -173,12 +183,18 @@ mod tests {
|
||||
async fn count(&self) -> Result<i64, DomainError> {
|
||||
self.0.count().await
|
||||
}
|
||||
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError> {
|
||||
self.0.list_paginated(page).await
|
||||
}
|
||||
async fn find_by_ids(&self, ids: &[UserId]) -> Result<std::collections::HashMap<UserId, User>, DomainError> {
|
||||
self.0.find_by_ids(ids).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserWriter for EmailConflictOnSaveStore {
|
||||
async fn save(&self, _user: &User) -> Result<(), DomainError> {
|
||||
Err(DomainError::Conflict("users_email_key".into()))
|
||||
Err(DomainError::UniqueViolation { field: "email" })
|
||||
}
|
||||
async fn update_profile(
|
||||
&self,
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
feed::{FeedEntry, PageParams, Paginated, UserSummary},
|
||||
user::User,
|
||||
},
|
||||
ports::{FeedQuery, FeedRepository, FollowRepository, TagRepository, UserReader},
|
||||
models::feed::{FeedEntry, PageParams, Paginated},
|
||||
ports::{FeedQuery, FeedRepository, FollowRepository},
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
@@ -15,88 +12,6 @@ pub async fn get_home_feed(
|
||||
page: PageParams,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
let mut following_ids = follows.get_accepted_following_ids(user_id).await?;
|
||||
following_ids.push(user_id.clone()); // include own thoughts in home feed
|
||||
following_ids.push(user_id.clone());
|
||||
feed.query(&FeedQuery::home(user_id.clone(), following_ids, page)).await
|
||||
}
|
||||
|
||||
pub async fn get_public_feed(
|
||||
feed: &dyn FeedRepository,
|
||||
viewer_id: Option<&UserId>,
|
||||
page: PageParams,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
feed.query(&FeedQuery::public(page, viewer_id.cloned())).await
|
||||
}
|
||||
|
||||
pub async fn get_user_feed(
|
||||
feed: &dyn FeedRepository,
|
||||
user_id: &UserId,
|
||||
page: PageParams,
|
||||
viewer_id: Option<&UserId>,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
feed.query(&FeedQuery::user(user_id.clone(), page, viewer_id.cloned())).await
|
||||
}
|
||||
|
||||
pub async fn get_followers(
|
||||
follows: &dyn FollowRepository,
|
||||
user_id: &UserId,
|
||||
page: PageParams,
|
||||
) -> Result<Paginated<User>, DomainError> {
|
||||
follows.list_followers(user_id, &page).await
|
||||
}
|
||||
|
||||
pub async fn get_following(
|
||||
follows: &dyn FollowRepository,
|
||||
user_id: &UserId,
|
||||
page: PageParams,
|
||||
) -> Result<Paginated<User>, DomainError> {
|
||||
follows.list_following(user_id, &page).await
|
||||
}
|
||||
|
||||
pub async fn get_by_tag(
|
||||
feed: &dyn FeedRepository,
|
||||
tag_name: &str,
|
||||
page: PageParams,
|
||||
viewer_id: Option<&UserId>,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
feed.query(&FeedQuery::tag(tag_name, page, viewer_id.cloned())).await
|
||||
}
|
||||
|
||||
pub async fn search(
|
||||
feed: &dyn FeedRepository,
|
||||
query: &str,
|
||||
page: PageParams,
|
||||
viewer_id: Option<&UserId>,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
feed.query(&FeedQuery::search(query, page, viewer_id.cloned())).await
|
||||
}
|
||||
|
||||
pub async fn list_users(users: &dyn UserReader) -> Result<Vec<UserSummary>, DomainError> {
|
||||
users.list_with_stats().await
|
||||
}
|
||||
|
||||
pub async fn list_users_paginated(
|
||||
users: &dyn UserReader,
|
||||
page: PageParams,
|
||||
) -> Result<Paginated<UserSummary>, DomainError> {
|
||||
let all = users.list_with_stats().await?;
|
||||
let total = all.len() as i64;
|
||||
let start = ((page.page.saturating_sub(1)) * page.per_page) as usize;
|
||||
let items: Vec<UserSummary> = all
|
||||
.into_iter()
|
||||
.skip(start)
|
||||
.take(page.per_page as usize)
|
||||
.collect();
|
||||
Ok(Paginated {
|
||||
items,
|
||||
total,
|
||||
page: page.page,
|
||||
per_page: page.per_page,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_popular_tags(
|
||||
tags: &dyn TagRepository,
|
||||
limit: usize,
|
||||
) -> Result<Vec<(String, i64)>, DomainError> {
|
||||
tags.popular_tags(limit).await
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::thought::{Thought, Visibility},
|
||||
ports::{EventPublisher, OutboxWriter, TagRepository, ThoughtRepository, UserReader},
|
||||
models::{
|
||||
feed::{EngagementStats, FeedEntry},
|
||||
thought::{Thought, Visibility},
|
||||
},
|
||||
ports::{EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository, UserReader},
|
||||
value_objects::{Content, ThoughtId, UserId},
|
||||
};
|
||||
|
||||
@@ -113,18 +116,65 @@ pub async fn edit_thought(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_thought(
|
||||
/// Fetches a single thought enriched with author + real engagement stats.
|
||||
pub async fn get_thought_view(
|
||||
thoughts: &dyn ThoughtRepository,
|
||||
users: &dyn UserReader,
|
||||
engagement: &dyn EngagementRepository,
|
||||
id: &ThoughtId,
|
||||
) -> Result<Thought, DomainError> {
|
||||
thoughts.find_by_id(id).await?.ok_or(DomainError::NotFound)
|
||||
viewer: Option<&UserId>,
|
||||
) -> Result<FeedEntry, DomainError> {
|
||||
let thought = thoughts
|
||||
.find_by_id(id)
|
||||
.await?
|
||||
.ok_or(DomainError::NotFound)?;
|
||||
let author = users
|
||||
.find_by_id(&thought.user_id)
|
||||
.await?
|
||||
.ok_or(DomainError::NotFound)?;
|
||||
let mut map = engagement.get_for_thoughts(&[id.clone()], viewer).await?;
|
||||
let (stats, viewer_ctx) = map.remove(id).unwrap_or(
|
||||
(EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, None)
|
||||
);
|
||||
Ok(FeedEntry { thought, author, stats, viewer: viewer_ctx })
|
||||
}
|
||||
|
||||
pub async fn get_thread(
|
||||
/// Fetches a thread (root + replies) enriched with authors + real engagement stats.
|
||||
/// Batches all DB lookups — one query per resource type regardless of thread length.
|
||||
pub async fn get_thread_views(
|
||||
thoughts: &dyn ThoughtRepository,
|
||||
id: &ThoughtId,
|
||||
) -> Result<Vec<Thought>, DomainError> {
|
||||
thoughts.get_thread(id).await
|
||||
users: &dyn UserReader,
|
||||
engagement: &dyn EngagementRepository,
|
||||
root_id: &ThoughtId,
|
||||
viewer: Option<&UserId>,
|
||||
) -> Result<Vec<FeedEntry>, DomainError> {
|
||||
let thread = thoughts.get_thread(root_id).await?;
|
||||
if thread.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let thought_ids: Vec<ThoughtId> = thread.iter().map(|t| t.id.clone()).collect();
|
||||
let user_ids: Vec<UserId> = thread.iter().map(|t| t.user_id.clone()).collect();
|
||||
|
||||
let (authors_map, engagement_map) = tokio::join!(
|
||||
users.find_by_ids(&user_ids),
|
||||
engagement.get_for_thoughts(&thought_ids, viewer),
|
||||
);
|
||||
let authors_map = authors_map?;
|
||||
let mut engagement_map = engagement_map?;
|
||||
|
||||
let mut entries = Vec::with_capacity(thread.len());
|
||||
for thought in thread {
|
||||
let author = authors_map
|
||||
.get(&thought.user_id)
|
||||
.cloned()
|
||||
.ok_or(DomainError::NotFound)?;
|
||||
let (stats, viewer_ctx) = engagement_map.remove(&thought.id).unwrap_or(
|
||||
(EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, None)
|
||||
);
|
||||
entries.push(FeedEntry { thought, author, stats, viewer: viewer_ctx });
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -323,3 +373,84 @@ mod tests {
|
||||
assert_eq!(reply.in_reply_to_id, Some(original.id.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod enrichment_tests {
|
||||
use super::*;
|
||||
use domain::testing::TestStore;
|
||||
use domain::models::user::User;
|
||||
use domain::models::thought::{Thought, Visibility};
|
||||
use domain::value_objects::*;
|
||||
use domain::ports::{ThoughtRepository, UserWriter};
|
||||
|
||||
fn make_user() -> User {
|
||||
User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("alice").unwrap(),
|
||||
Email::new("a@a.com").unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
)
|
||||
}
|
||||
|
||||
fn make_thought(user_id: UserId) -> Thought {
|
||||
Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
user_id,
|
||||
Content::new_local(String::from("hello")).unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_thought_view_returns_feed_entry() {
|
||||
let store = TestStore::default();
|
||||
let user = make_user();
|
||||
<TestStore as UserWriter>::save(&store, &user).await.unwrap();
|
||||
let thought = make_thought(user.id.clone());
|
||||
<TestStore as ThoughtRepository>::save(&store, &thought).await.unwrap();
|
||||
|
||||
let entry = get_thought_view(&store, &store, &store, &thought.id, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(entry.thought.id, thought.id);
|
||||
assert_eq!(entry.author.id, user.id);
|
||||
assert_eq!(entry.stats.like_count, 0);
|
||||
assert!(entry.viewer.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_thought_view_returns_not_found_for_missing_thought() {
|
||||
let store = TestStore::default();
|
||||
let err = get_thought_view(&store, &store, &store, &ThoughtId::new(), None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::NotFound));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_thread_views_batches_correctly() {
|
||||
let store = TestStore::default();
|
||||
let user = make_user();
|
||||
<TestStore as UserWriter>::save(&store, &user).await.unwrap();
|
||||
let root = make_thought(user.id.clone());
|
||||
<TestStore as ThoughtRepository>::save(&store, &root).await.unwrap();
|
||||
let reply = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
user.id.clone(),
|
||||
Content::new_local(String::from("reply")).unwrap(),
|
||||
Some(root.id.clone()),
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
<TestStore as ThoughtRepository>::save(&store, &reply).await.unwrap();
|
||||
|
||||
let entries = get_thread_views(&store, &store, &store, &root.id, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@ use std::sync::Arc;
|
||||
|
||||
use activitypub::ThoughtsObjectHandler;
|
||||
use activitypub_base::service::ActivityPubService;
|
||||
use auth::ApiKeyServiceImpl;
|
||||
use domain::{errors::DomainError, events::DomainEvent, ports::{EventPublisher, OutboxWriter}};
|
||||
use event_transport::EventPublisherAdapter;
|
||||
use nats::NatsTransport;
|
||||
use postgres::activitypub::PgActivityPubRepository;
|
||||
use postgres::engagement::PgEngagementRepository;
|
||||
use postgres::outbox::PgOutboxWriter;
|
||||
use postgres::remote_actor_connections::PgRemoteActorConnectionRepository;
|
||||
use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository};
|
||||
@@ -127,6 +129,10 @@ pub async fn build(cfg: &Config) -> Infrastructure {
|
||||
ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())),
|
||||
remote_actor_connections: Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())),
|
||||
federation_scheduler: ap_service.clone() as Arc<dyn domain::ports::FederationSchedulerPort>,
|
||||
api_key_auth: Arc::new(ApiKeyServiceImpl::new(
|
||||
Arc::new(postgres::api_key::PgApiKeyRepository::new(pool.clone())),
|
||||
)),
|
||||
engagement: Arc::new(PgEngagementRepository::new(pool.clone())),
|
||||
};
|
||||
|
||||
Infrastructure { state, ap_service }
|
||||
|
||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[features]
|
||||
test-helpers = []
|
||||
test-helpers = ["dep:sha2", "dep:hex"]
|
||||
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
@@ -14,6 +14,10 @@ chrono = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
url = { workspace = true }
|
||||
sha2 = { version = "0.10", optional = true }
|
||||
hex = { version = "0.4", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
@@ -10,6 +10,8 @@ pub enum DomainError {
|
||||
Forbidden,
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
#[error("unique violation on field: {field}")]
|
||||
UniqueViolation { field: &'static str },
|
||||
#[error("invalid input: {0}")]
|
||||
InvalidInput(String),
|
||||
#[error("external service error: {0}")]
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
events::{DomainEvent, EventEnvelope},
|
||||
models::{
|
||||
api_key::ApiKey,
|
||||
feed::{FeedEntry, PageParams, Paginated, UserSummary},
|
||||
feed::{EngagementStats, FeedEntry, PageParams, Paginated, UserSummary, ViewerContext},
|
||||
notification::Notification,
|
||||
remote_actor::RemoteActor,
|
||||
social::{Block, Boost, Follow, FollowState, Like},
|
||||
@@ -56,6 +58,8 @@ pub trait UserReader: Send + Sync {
|
||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
|
||||
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError>;
|
||||
async fn count(&self) -> Result<i64, DomainError>;
|
||||
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError>;
|
||||
async fn find_by_ids(&self, ids: &[UserId]) -> Result<HashMap<UserId, User>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -115,6 +119,15 @@ pub trait BoostRepository: Send + Sync {
|
||||
async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result<i64, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait EngagementRepository: Send + Sync {
|
||||
async fn get_for_thoughts(
|
||||
&self,
|
||||
thought_ids: &[ThoughtId],
|
||||
viewer_id: Option<&UserId>,
|
||||
) -> Result<HashMap<ThoughtId, (EngagementStats, Option<ViewerContext>)>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait FollowRepository: Send + Sync {
|
||||
async fn save(&self, follow: &Follow) -> Result<(), DomainError>;
|
||||
@@ -180,6 +193,11 @@ pub trait ApiKeyRepository: Send + Sync {
|
||||
async fn delete(&self, id: &ApiKeyId, user_id: &UserId) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ApiKeyService: Send + Sync {
|
||||
async fn validate_key(&self, raw_key: &str) -> Result<Option<UserId>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait TopFriendRepository: Send + Sync {
|
||||
async fn set_top_friends(
|
||||
|
||||
@@ -82,6 +82,23 @@ impl UserReader for TestStore {
|
||||
.filter(|u| u.local)
|
||||
.count() as i64)
|
||||
}
|
||||
|
||||
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError> {
|
||||
let all = self.list_with_stats().await?;
|
||||
let total = all.len() as i64;
|
||||
let start = page.offset() as usize;
|
||||
let items: Vec<UserSummary> = all.into_iter().skip(start).take(page.limit() as usize).collect();
|
||||
Ok(Paginated { items, total, page: page.page, per_page: page.per_page })
|
||||
}
|
||||
|
||||
async fn find_by_ids(&self, ids: &[UserId]) -> Result<HashMap<UserId, User>, DomainError> {
|
||||
let g = self.users.lock().unwrap();
|
||||
let map = g.iter()
|
||||
.filter(|u| ids.contains(&u.id))
|
||||
.map(|u| (u.id.clone(), u.clone()))
|
||||
.collect();
|
||||
Ok(map)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -271,6 +288,33 @@ impl BoostRepository for TestStore {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EngagementRepository for TestStore {
|
||||
async fn get_for_thoughts(
|
||||
&self,
|
||||
thought_ids: &[ThoughtId],
|
||||
viewer_id: Option<&UserId>,
|
||||
) -> Result<HashMap<ThoughtId, (crate::models::feed::EngagementStats, Option<crate::models::feed::ViewerContext>)>, DomainError> {
|
||||
use crate::models::feed::{EngagementStats, ViewerContext};
|
||||
let likes = self.likes.lock().unwrap();
|
||||
let boosts = self.boosts.lock().unwrap();
|
||||
let thoughts = self.thoughts.lock().unwrap();
|
||||
|
||||
let mut result = HashMap::new();
|
||||
for tid in thought_ids {
|
||||
let like_count = likes.iter().filter(|l| &l.thought_id == tid).count() as i64;
|
||||
let boost_count = boosts.iter().filter(|b| &b.thought_id == tid).count() as i64;
|
||||
let reply_count = thoughts.iter().filter(|t| t.in_reply_to_id.as_ref() == Some(tid)).count() as i64;
|
||||
let viewer = viewer_id.map(|vid| ViewerContext {
|
||||
liked: likes.iter().any(|l| &l.thought_id == tid && &l.user_id == vid),
|
||||
boosted: boosts.iter().any(|b| &b.thought_id == tid && &b.user_id == vid),
|
||||
});
|
||||
result.insert(tid.clone(), (EngagementStats { like_count, boost_count, reply_count }, viewer));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FollowRepository for TestStore {
|
||||
async fn save(&self, follow: &Follow) -> Result<(), DomainError> {
|
||||
@@ -456,6 +500,21 @@ impl ApiKeyRepository for TestStore {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ApiKeyService for TestStore {
|
||||
async fn validate_key(&self, raw_key: &str) -> Result<Option<UserId>, DomainError> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let hash = hex::encode(Sha256::digest(raw_key.as_bytes()));
|
||||
Ok(self
|
||||
.api_keys
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|k| k.key_hash == hash)
|
||||
.map(|k| k.user_id.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TopFriendRepository for TestStore {
|
||||
async fn set_top_friends(
|
||||
|
||||
@@ -17,8 +17,6 @@ uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
url = { workspace = true }
|
||||
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid", "chrono"] }
|
||||
utoipa-scalar = { version = "0.3.0", features = ["axum"], default-features = false }
|
||||
|
||||
@@ -27,6 +27,9 @@ impl IntoResponse for ApiError {
|
||||
}
|
||||
Self::Domain(DomainError::Forbidden) => (StatusCode::FORBIDDEN, "forbidden".into()),
|
||||
Self::Domain(DomainError::Conflict(m)) => (StatusCode::CONFLICT, m),
|
||||
Self::Domain(DomainError::UniqueViolation { field }) => {
|
||||
(StatusCode::CONFLICT, format!("{field} already taken"))
|
||||
}
|
||||
Self::Domain(DomainError::InvalidInput(m)) => (StatusCode::UNPROCESSABLE_ENTITY, m),
|
||||
Self::Domain(DomainError::ExternalService(_)) => {
|
||||
(StatusCode::BAD_GATEWAY, "external service error".into())
|
||||
|
||||
@@ -2,6 +2,27 @@ use crate::{errors::ApiError, state::AppState};
|
||||
use axum::{extract::FromRequestParts, http::request::Parts};
|
||||
use domain::value_objects::UserId;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deps_struct! — generates Deps struct + impl FromAppState from a field list.
|
||||
// Field names must match AppState exactly (enforced at compile time).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! deps_struct {
|
||||
( $name:ident { $( $field:ident : $trait:path ),+ $(,)? } ) => {
|
||||
pub struct $name {
|
||||
$( pub $field: ::std::sync::Arc<dyn $trait>, )+
|
||||
}
|
||||
impl $crate::extractors::FromAppState for $name {
|
||||
fn from_state(s: &$crate::state::AppState) -> Self {
|
||||
Self {
|
||||
$( $field: ::std::sync::Arc::clone(&s.$field), )+
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deps<S> extractor — narrows AppState to a handler-specific deps struct
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -23,6 +44,10 @@ impl<S: FromAppState + Send + 'static> FromRequestParts<AppState> for Deps<S> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth extractors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub struct AuthUser(pub UserId);
|
||||
pub struct OptionalAuthUser(pub Option<UserId>);
|
||||
|
||||
@@ -57,17 +82,12 @@ async fn extract_user_id(parts: &mut Parts, state: &AppState) -> Result<Option<U
|
||||
}
|
||||
if let Some(key_header) = parts.headers.get("X-Api-Key") {
|
||||
if let Ok(raw) = key_header.to_str() {
|
||||
let hash = sha256_hex(raw);
|
||||
if let Ok(Some(key)) = state.api_keys.find_by_hash(&hash).await {
|
||||
return Ok(Some(key.user_id));
|
||||
}
|
||||
return state
|
||||
.api_key_auth
|
||||
.validate_key(raw)
|
||||
.await
|
||||
.map_err(|_| ApiError::Unauthorized);
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn sha256_hex(s: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let hash = Sha256::digest(s.as_bytes());
|
||||
hex::encode(hash)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::{
|
||||
deps_struct,
|
||||
errors::ApiError,
|
||||
extractors::{AuthUser, Deps, FromAppState},
|
||||
state::AppState,
|
||||
extractors::{AuthUser, Deps},
|
||||
};
|
||||
use api_types::{
|
||||
requests::CreateApiKeyRequest,
|
||||
@@ -14,20 +14,11 @@ use axum::{
|
||||
Json,
|
||||
};
|
||||
use domain::{ports::ApiKeyRepository, value_objects::ApiKeyId};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct ApiKeysDeps {
|
||||
pub api_keys: Arc<dyn ApiKeyRepository>,
|
||||
}
|
||||
|
||||
impl FromAppState for ApiKeysDeps {
|
||||
fn from_state(s: &AppState) -> Self {
|
||||
Self {
|
||||
api_keys: s.api_keys.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
deps_struct!(ApiKeysDeps {
|
||||
api_keys: ApiKeyRepository,
|
||||
});
|
||||
|
||||
#[utoipa::path(get, path = "/api-keys", responses((status = 200, description = "API keys", body = Vec<ApiKeyResponse>)), security(("bearer_auth" = [])))]
|
||||
pub async fn get_api_keys(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::{
|
||||
deps_struct,
|
||||
errors::ApiError,
|
||||
extractors::{Deps, FromAppState},
|
||||
state::AppState,
|
||||
extractors::Deps,
|
||||
};
|
||||
use api_types::{
|
||||
requests::{LoginRequest, RegisterRequest},
|
||||
@@ -10,25 +10,13 @@ use api_types::{
|
||||
use application::use_cases::auth::{login, register, LoginInput, RegisterInput};
|
||||
use axum::{http::StatusCode, response::IntoResponse, Json};
|
||||
use domain::ports::{AuthService, EventPublisher, PasswordHasher, UserRepository};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct AuthDeps {
|
||||
pub users: Arc<dyn UserRepository>,
|
||||
pub hasher: Arc<dyn PasswordHasher>,
|
||||
pub auth: Arc<dyn AuthService>,
|
||||
pub events: Arc<dyn EventPublisher>,
|
||||
}
|
||||
|
||||
impl FromAppState for AuthDeps {
|
||||
fn from_state(s: &AppState) -> Self {
|
||||
Self {
|
||||
users: s.users.clone(),
|
||||
hasher: s.hasher.clone(),
|
||||
auth: s.auth.clone(),
|
||||
events: s.events.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
deps_struct!(AuthDeps {
|
||||
users: UserRepository,
|
||||
hasher: PasswordHasher,
|
||||
auth: AuthService,
|
||||
events: EventPublisher,
|
||||
});
|
||||
|
||||
pub fn to_user_response(u: &domain::models::user::User) -> UserResponse {
|
||||
UserResponse {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::{
|
||||
deps_struct,
|
||||
errors::ApiError,
|
||||
extractors::{AuthUser, Deps, FromAppState},
|
||||
state::AppState,
|
||||
extractors::{AuthUser, Deps},
|
||||
};
|
||||
use api_types::responses::{ProfileField, RemoteActorResponse};
|
||||
use application::use_cases::federation_management::{
|
||||
@@ -11,7 +11,6 @@ use application::use_cases::federation_management::{
|
||||
use axum::{http::StatusCode, Json};
|
||||
use domain::ports::{EventPublisher, FederationActionPort, FollowRepository, UserRepository};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ActorUrlBody {
|
||||
@@ -23,23 +22,12 @@ pub struct HandleBody {
|
||||
pub handle: String,
|
||||
}
|
||||
|
||||
pub struct FederationManagementDeps {
|
||||
pub federation: Arc<dyn FederationActionPort>,
|
||||
pub follows: Arc<dyn FollowRepository>,
|
||||
pub users: Arc<dyn UserRepository>,
|
||||
pub events: Arc<dyn EventPublisher>,
|
||||
}
|
||||
|
||||
impl FromAppState for FederationManagementDeps {
|
||||
fn from_state(s: &AppState) -> Self {
|
||||
Self {
|
||||
federation: s.federation.clone(),
|
||||
follows: s.follows.clone(),
|
||||
users: s.users.clone(),
|
||||
events: s.events.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
deps_struct!(FederationManagementDeps {
|
||||
federation: FederationActionPort,
|
||||
follows: FollowRepository,
|
||||
users: UserRepository,
|
||||
events: EventPublisher,
|
||||
});
|
||||
|
||||
fn to_response(a: domain::models::remote_actor::RemoteActor) -> RemoteActorResponse {
|
||||
RemoteActorResponse {
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
use crate::{
|
||||
deps_struct,
|
||||
errors::ApiError,
|
||||
extractors::{Deps, FromAppState, OptionalAuthUser, AuthUser},
|
||||
extractors::{AuthUser, Deps, OptionalAuthUser},
|
||||
handlers::auth::to_user_response,
|
||||
state::AppState,
|
||||
};
|
||||
use api_types::requests::{PaginationQuery, SearchQuery};
|
||||
use api_types::responses::ThoughtResponse;
|
||||
use application::use_cases::feed::{
|
||||
get_by_tag, get_followers, get_following, get_home_feed,
|
||||
get_popular_tags as uc_get_popular_tags, get_public_feed, get_user_feed,
|
||||
};
|
||||
use application::use_cases::feed::get_home_feed;
|
||||
use application::use_cases::profile::{get_user_by_id_or_username, get_user_by_username};
|
||||
use axum::{
|
||||
extract::{Path, Query},
|
||||
@@ -19,31 +16,17 @@ use axum::{
|
||||
};
|
||||
use domain::{
|
||||
models::feed::PageParams,
|
||||
ports::{FederationActionPort, FeedRepository, FollowRepository, SearchPort, TagRepository, UserRepository},
|
||||
ports::{FederationActionPort, FeedQuery, FeedRepository, FollowRepository, SearchPort, TagRepository, UserRepository},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct FeedDeps {
|
||||
pub feed: Arc<dyn FeedRepository>,
|
||||
pub follows: Arc<dyn FollowRepository>,
|
||||
pub search: Arc<dyn SearchPort>,
|
||||
pub federation: Arc<dyn FederationActionPort>,
|
||||
pub users: Arc<dyn UserRepository>,
|
||||
pub tags: Arc<dyn TagRepository>,
|
||||
}
|
||||
|
||||
impl FromAppState for FeedDeps {
|
||||
fn from_state(s: &AppState) -> Self {
|
||||
Self {
|
||||
feed: s.feed.clone(),
|
||||
follows: s.follows.clone(),
|
||||
search: s.search.clone(),
|
||||
federation: s.federation.clone(),
|
||||
users: s.users.clone(),
|
||||
tags: s.tags.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
deps_struct!(FeedDeps {
|
||||
feed: FeedRepository,
|
||||
follows: FollowRepository,
|
||||
search: SearchPort,
|
||||
federation: FederationActionPort,
|
||||
users: UserRepository,
|
||||
tags: TagRepository,
|
||||
});
|
||||
|
||||
pub fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse {
|
||||
ThoughtResponse {
|
||||
@@ -103,7 +86,7 @@ pub async fn public_feed(
|
||||
page: q.page(),
|
||||
per_page: q.per_page(),
|
||||
};
|
||||
let result = get_public_feed(&*d.feed, viewer.as_ref(), page).await?;
|
||||
let result = d.feed.query(&FeedQuery::public(page, viewer)).await?;
|
||||
Ok(Json(serde_json::json!({
|
||||
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
|
||||
"total": result.total,
|
||||
@@ -179,7 +162,7 @@ pub async fn get_following_handler(
|
||||
page: q.page(),
|
||||
per_page: q.per_page(),
|
||||
};
|
||||
let result = get_following(&*d.follows, &user.id, page).await?;
|
||||
let result = d.follows.list_following(&user.id, &page).await?;
|
||||
Ok(Json(serde_json::json!({
|
||||
"total": result.total,
|
||||
"items": result.items.iter().map(to_user_response).collect::<Vec<_>>()
|
||||
@@ -214,7 +197,7 @@ pub async fn get_followers_handler(
|
||||
page: q.page(),
|
||||
per_page: q.per_page(),
|
||||
};
|
||||
let result = get_followers(&*d.follows, &user.id, page).await?;
|
||||
let result = d.follows.list_followers(&user.id, &page).await?;
|
||||
Ok(Json(serde_json::json!({
|
||||
"total": result.total,
|
||||
"items": result.items.iter().map(to_user_response).collect::<Vec<_>>()
|
||||
@@ -241,7 +224,7 @@ pub async fn user_thoughts_handler(
|
||||
page: q.page(),
|
||||
per_page: q.per_page(),
|
||||
};
|
||||
let result = get_user_feed(&*d.feed, &user.id, page, viewer.as_ref()).await?;
|
||||
let result = d.feed.query(&FeedQuery::user(user.id.clone(), page, viewer)).await?;
|
||||
Ok(Json(serde_json::json!({
|
||||
"total": result.total,
|
||||
"page": result.page,
|
||||
@@ -258,11 +241,7 @@ pub async fn get_popular_tags(
|
||||
.get("limit")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(api_types::requests::DEFAULT_PER_PAGE as usize);
|
||||
let tags = uc_get_popular_tags(
|
||||
&*d.tags,
|
||||
limit.min(api_types::requests::MAX_PER_PAGE as usize),
|
||||
)
|
||||
.await?;
|
||||
let tags = d.tags.popular_tags(limit.min(api_types::requests::MAX_PER_PAGE as usize)).await?;
|
||||
Ok(Json(serde_json::json!({
|
||||
"tags": tags.iter().map(|(name, count)| serde_json::json!({
|
||||
"name": name,
|
||||
@@ -289,7 +268,7 @@ pub async fn tag_thoughts_handler(
|
||||
page: q.page(),
|
||||
per_page: q.per_page(),
|
||||
};
|
||||
let result = get_by_tag(&*d.feed, &tag_name, page, viewer.as_ref()).await?;
|
||||
let result = d.feed.query(&FeedQuery::tag(&tag_name, page, viewer)).await?;
|
||||
Ok(Json(serde_json::json!({
|
||||
"tag": tag_name,
|
||||
"total": result.total,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::{
|
||||
deps_struct,
|
||||
errors::ApiError,
|
||||
extractors::{AuthUser, Deps, FromAppState},
|
||||
state::AppState,
|
||||
extractors::{AuthUser, Deps},
|
||||
};
|
||||
use api_types::requests::NotificationUpdateRequest;
|
||||
use application::use_cases::notifications::{
|
||||
@@ -16,20 +16,11 @@ use axum::{
|
||||
use domain::{
|
||||
models::feed::PageParams, ports::NotificationRepository, value_objects::NotificationId,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct NotificationsDeps {
|
||||
pub notifications: Arc<dyn NotificationRepository>,
|
||||
}
|
||||
|
||||
impl FromAppState for NotificationsDeps {
|
||||
fn from_state(s: &AppState) -> Self {
|
||||
Self {
|
||||
notifications: s.notifications.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
deps_struct!(NotificationsDeps {
|
||||
notifications: NotificationRepository,
|
||||
});
|
||||
|
||||
#[utoipa::path(get, path = "/notifications", responses((status = 200, description = "Notification summary")), security(("bearer_auth" = [])))]
|
||||
pub async fn list_notifications(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::{
|
||||
deps_struct,
|
||||
errors::ApiError,
|
||||
extractors::{AuthUser, Deps, FromAppState},
|
||||
state::AppState,
|
||||
extractors::{AuthUser, Deps},
|
||||
};
|
||||
use api_types::requests::SetTopFriendsRequest;
|
||||
use api_types::responses::TopFriendsResponse;
|
||||
@@ -20,34 +20,18 @@ use domain::{
|
||||
},
|
||||
value_objects::{ThoughtId, UserId},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct SocialDeps {
|
||||
pub likes: Arc<dyn LikeRepository>,
|
||||
pub boosts: Arc<dyn BoostRepository>,
|
||||
pub follows: Arc<dyn FollowRepository>,
|
||||
pub users: Arc<dyn UserRepository>,
|
||||
pub federation: Arc<dyn FederationActionPort>,
|
||||
pub events: Arc<dyn EventPublisher>,
|
||||
pub blocks: Arc<dyn BlockRepository>,
|
||||
pub top_friends: Arc<dyn TopFriendRepository>,
|
||||
}
|
||||
|
||||
impl FromAppState for SocialDeps {
|
||||
fn from_state(s: &AppState) -> Self {
|
||||
Self {
|
||||
likes: s.likes.clone(),
|
||||
boosts: s.boosts.clone(),
|
||||
follows: s.follows.clone(),
|
||||
users: s.users.clone(),
|
||||
federation: s.federation.clone(),
|
||||
events: s.events.clone(),
|
||||
blocks: s.blocks.clone(),
|
||||
top_friends: s.top_friends.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
deps_struct!(SocialDeps {
|
||||
likes: LikeRepository,
|
||||
boosts: BoostRepository,
|
||||
follows: FollowRepository,
|
||||
users: UserRepository,
|
||||
federation: FederationActionPort,
|
||||
events: EventPublisher,
|
||||
blocks: BlockRepository,
|
||||
top_friends: TopFriendRepository,
|
||||
});
|
||||
|
||||
#[utoipa::path(post, path = "/thoughts/{id}/like", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Liked")), security(("bearer_auth" = [])))]
|
||||
pub async fn post_like(
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
use crate::{
|
||||
deps_struct,
|
||||
errors::ApiError,
|
||||
extractors::{AuthUser, Deps, FromAppState, OptionalAuthUser},
|
||||
handlers::auth::to_user_response,
|
||||
state::AppState,
|
||||
extractors::{AuthUser, Deps, OptionalAuthUser},
|
||||
handlers::feed::to_thought_response,
|
||||
};
|
||||
use api_types::{
|
||||
requests::{CreateThoughtRequest, EditThoughtRequest},
|
||||
responses::ErrorResponse,
|
||||
};
|
||||
use application::use_cases::thoughts::{
|
||||
create_thought, delete_thought, edit_thought, get_thought, get_thread, CreateThoughtInput,
|
||||
create_thought, delete_thought, edit_thought, get_thread_views, get_thought_view,
|
||||
CreateThoughtInput,
|
||||
};
|
||||
use axum::{
|
||||
extract::Path,
|
||||
@@ -18,56 +19,20 @@ use axum::{
|
||||
Json,
|
||||
};
|
||||
use domain::{
|
||||
ports::{EventPublisher, OutboxWriter, TagRepository, ThoughtRepository, UserRepository},
|
||||
models::feed::{EngagementStats, FeedEntry, ViewerContext},
|
||||
ports::{EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository, UserRepository},
|
||||
value_objects::ThoughtId,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct ThoughtsDeps {
|
||||
pub thoughts: Arc<dyn ThoughtRepository>,
|
||||
pub users: Arc<dyn UserRepository>,
|
||||
pub tags: Arc<dyn TagRepository>,
|
||||
pub events: Arc<dyn EventPublisher>,
|
||||
pub outbox: Arc<dyn OutboxWriter>,
|
||||
}
|
||||
|
||||
impl FromAppState for ThoughtsDeps {
|
||||
fn from_state(s: &AppState) -> Self {
|
||||
Self {
|
||||
thoughts: s.thoughts.clone(),
|
||||
users: s.users.clone(),
|
||||
tags: s.tags.clone(),
|
||||
events: s.events.clone(),
|
||||
outbox: s.outbox.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn thought_to_json(
|
||||
t: &domain::models::thought::Thought,
|
||||
author: &domain::models::user::User,
|
||||
like_count: i64,
|
||||
boost_count: i64,
|
||||
reply_count: i64,
|
||||
) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"id": t.id.as_uuid(),
|
||||
"content": t.content.as_str(),
|
||||
"author": to_user_response(author),
|
||||
"replyToId": t.in_reply_to_id.as_ref().map(|x| x.as_uuid()),
|
||||
"visibility": t.visibility.as_str(),
|
||||
"contentWarning": t.content_warning,
|
||||
"sensitive": t.sensitive,
|
||||
"likeCount": like_count,
|
||||
"boostCount": boost_count,
|
||||
"replyCount": reply_count,
|
||||
"likedByViewer": false,
|
||||
"boostedByViewer": false,
|
||||
"createdAt": t.created_at,
|
||||
"updatedAt": t.updated_at,
|
||||
})
|
||||
}
|
||||
deps_struct!(ThoughtsDeps {
|
||||
thoughts: ThoughtRepository,
|
||||
users: UserRepository,
|
||||
tags: TagRepository,
|
||||
events: EventPublisher,
|
||||
outbox: OutboxWriter,
|
||||
engagement: EngagementRepository,
|
||||
});
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/thoughts",
|
||||
@@ -106,10 +71,13 @@ pub async fn post_thought(
|
||||
.find_by_id(&uid)
|
||||
.await?
|
||||
.ok_or(domain::errors::DomainError::NotFound)?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(thought_to_json(&out.thought, &author, 0, 0, 0)),
|
||||
))
|
||||
let entry = FeedEntry {
|
||||
thought: out.thought,
|
||||
author,
|
||||
stats: EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 },
|
||||
viewer: Some(ViewerContext { liked: false, boosted: false }),
|
||||
};
|
||||
Ok((StatusCode::CREATED, Json(to_thought_response(&entry))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -123,15 +91,17 @@ pub async fn post_thought(
|
||||
pub async fn get_thought_handler(
|
||||
Deps(d): Deps<ThoughtsDeps>,
|
||||
Path(id): Path<Uuid>,
|
||||
OptionalAuthUser(_viewer): OptionalAuthUser,
|
||||
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let thought = get_thought(&*d.thoughts, &ThoughtId::from_uuid(id)).await?;
|
||||
let author = d
|
||||
.users
|
||||
.find_by_id(&thought.user_id)
|
||||
.await?
|
||||
.ok_or(domain::errors::DomainError::NotFound)?;
|
||||
Ok(Json(thought_to_json(&thought, &author, 0, 0, 0)))
|
||||
let entry = get_thought_view(
|
||||
&*d.thoughts,
|
||||
&*d.users,
|
||||
&*d.engagement,
|
||||
&ThoughtId::from_uuid(id),
|
||||
viewer.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(serde_json::to_value(to_thought_response(&entry)).unwrap()))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -191,13 +161,19 @@ pub async fn patch_thought(
|
||||
pub async fn get_thread_handler(
|
||||
Deps(d): Deps<ThoughtsDeps>,
|
||||
Path(id): Path<Uuid>,
|
||||
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||
) -> Result<Json<Vec<serde_json::Value>>, ApiError> {
|
||||
let thoughts = get_thread(&*d.thoughts, &ThoughtId::from_uuid(id)).await?;
|
||||
let mut items = Vec::new();
|
||||
for t in &thoughts {
|
||||
if let Ok(Some(author)) = d.users.find_by_id(&t.user_id).await {
|
||||
items.push(thought_to_json(t, &author, 0, 0, 0));
|
||||
}
|
||||
}
|
||||
let entries = get_thread_views(
|
||||
&*d.thoughts,
|
||||
&*d.users,
|
||||
&*d.engagement,
|
||||
&ThoughtId::from_uuid(id),
|
||||
viewer.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
let items: Vec<_> = entries
|
||||
.iter()
|
||||
.map(|e| serde_json::to_value(to_thought_response(e)).unwrap())
|
||||
.collect();
|
||||
Ok(Json(items))
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ use api_types::{
|
||||
requests::{PaginationQuery, UpdateProfileRequest},
|
||||
responses::{ErrorResponse, ProfileField, RemoteActorResponse, UserResponse},
|
||||
};
|
||||
use application::use_cases::feed::list_users_paginated;
|
||||
use application::use_cases::profile::{
|
||||
get_user as fetch_user, get_user_by_id_or_username, update_profile,
|
||||
};
|
||||
@@ -129,13 +128,12 @@ pub async fn get_me_following(
|
||||
AuthUser(uid): AuthUser,
|
||||
Query(q): Query<PaginationQuery>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
use application::use_cases::feed::get_following;
|
||||
use domain::models::feed::PageParams;
|
||||
let page = PageParams {
|
||||
page: q.page(),
|
||||
per_page: q.per_page(),
|
||||
};
|
||||
let result = get_following(&*d.follows, &uid, page).await?;
|
||||
let result = d.follows.list_following(&uid, &page).await?;
|
||||
Ok(Json(serde_json::json!({
|
||||
"total": result.total,
|
||||
"items": result.items.iter().map(to_user_response).collect::<Vec<_>>(),
|
||||
@@ -169,7 +167,7 @@ pub async fn get_users(
|
||||
})));
|
||||
}
|
||||
|
||||
let result = list_users_paginated(&*d.users, page_params).await?;
|
||||
let result = d.users.list_paginated(page_params).await?;
|
||||
let items: Vec<_> = result
|
||||
.items
|
||||
.iter()
|
||||
|
||||
@@ -12,6 +12,7 @@ pub struct AppState {
|
||||
pub blocks: Arc<dyn BlockRepository>,
|
||||
pub tags: Arc<dyn TagRepository>,
|
||||
pub api_keys: Arc<dyn ApiKeyRepository>,
|
||||
pub api_key_auth: Arc<dyn ApiKeyService>,
|
||||
pub top_friends: Arc<dyn TopFriendRepository>,
|
||||
pub notifications: Arc<dyn NotificationRepository>,
|
||||
pub remote_actors: Arc<dyn RemoteActorRepository>,
|
||||
@@ -25,4 +26,5 @@ pub struct AppState {
|
||||
pub ap_repo: Arc<dyn ActivityPubRepository>,
|
||||
pub remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>,
|
||||
pub federation_scheduler: Arc<dyn FederationSchedulerPort>,
|
||||
pub engagement: Arc<dyn EngagementRepository>,
|
||||
}
|
||||
|
||||
@@ -133,5 +133,7 @@ pub fn make_state() -> AppState {
|
||||
ap_repo: Arc::new(NoOpApRepo),
|
||||
remote_actor_connections: store.clone(),
|
||||
federation_scheduler: store.clone(),
|
||||
api_key_auth: store.clone(),
|
||||
engagement: store.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user