Compare commits

...

16 Commits

Author SHA1 Message Date
2754e3e820 chore(application): fix unused import and clippy warnings in thoughts use cases
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 5m7s
test / unit (pull_request) Successful in 16m39s
test / integration (pull_request) Failing after 17m43s
2026-05-16 11:31:07 +02:00
4b1e7565ac feat(bootstrap): wire ApiKeyServiceImpl + PgEngagementRepository 2026-05-16 11:21:19 +02:00
5618da7d37 refactor(presentation): replace impl FromAppState boilerplate with deps_struct! macro in remaining handlers 2026-05-16 11:19:04 +02:00
e9f7851400 refactor(presentation/feed): call ports directly — remove shallow use case wrappers 2026-05-16 11:16:57 +02:00
d701a40e61 feat(presentation/thoughts): use enrichment use cases — real engagement stats, no hardcoded zeros 2026-05-16 11:16:54 +02:00
eea1d3fe24 feat(presentation): add deps_struct! macro; add api_key_auth + engagement to AppState; use ApiKeyService in extractor 2026-05-16 11:10:45 +02:00
90866aea58 refactor(application): delete shallow feed use cases — keep only get_home_feed 2026-05-16 11:09:26 +02:00
2d2b5dde6a feat(application): add get_thought_view + get_thread_views use cases with real engagement stats 2026-05-16 11:08:31 +02:00
bf5fd618cb feat(postgres): add PgEngagementRepository with batch stats query 2026-05-16 11:04:54 +02:00
ae8a3dc6ed feat(postgres): add list_paginated + find_by_ids to PgUserRepository 2026-05-16 11:04:33 +02:00
0222a168db feat(auth): add ApiKeyServiceImpl — moves sha256 hashing out of presentation 2026-05-16 11:03:07 +02:00
d3223923e4 feat(domain/testing): add ApiKeyService, EngagementRepository, list_paginated, find_by_ids to TestStore 2026-05-16 11:01:54 +02:00
f4db518167 feat(domain): add ApiKeyService, EngagementRepository ports; extend UserReader with list_paginated + find_by_ids 2026-05-16 10:59:32 +02:00
8628acfb77 refactor(application): use UniqueViolation in register — remove postgres constraint strings 2026-05-16 10:58:14 +02:00
1ab3766ce8 fix(postgres): map unique constraint violations to DomainError::UniqueViolation 2026-05-16 10:57:04 +02:00
ca35e8e774 feat(domain): add DomainError::UniqueViolation {field} 2026-05-16 10:55:58 +02:00
28 changed files with 663 additions and 329 deletions

View File

@@ -15,3 +15,5 @@ jsonwebtoken = "9"
argon2 = "0.5"
bcrypt = "0.15"
rand = "0.8"
sha2 = "0.10"
hex = "0.4"

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

View File

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

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

View File

@@ -1,4 +1,5 @@
pub mod activitypub;
pub mod engagement;
pub mod api_key;
pub mod block;
pub mod boost;

View File

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

View File

@@ -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"] }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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