Compare commits
4 Commits
4890501512
...
d50c13a2db
| Author | SHA1 | Date | |
|---|---|---|---|
| d50c13a2db | |||
| 004f3cd4d2 | |||
| 970f5a1644 | |||
| ecba9267cf |
@@ -45,9 +45,19 @@ struct FeedRow {
|
||||
like_count: i64,
|
||||
boost_count: i64,
|
||||
reply_count: i64,
|
||||
liked_by_viewer: bool,
|
||||
boosted_by_viewer: bool,
|
||||
}
|
||||
|
||||
const FEED_SELECT: &str = "
|
||||
fn feed_select(viewer: Option<uuid::Uuid>) -> String {
|
||||
let viewer_checks = match viewer {
|
||||
Some(uid) => format!(
|
||||
"EXISTS(SELECT 1 FROM likes WHERE user_id='{uid}' AND thought_id=t.id) AS liked_by_viewer,
|
||||
EXISTS(SELECT 1 FROM boosts WHERE user_id='{uid}' AND thought_id=t.id) AS boosted_by_viewer"
|
||||
),
|
||||
None => "false AS liked_by_viewer, false AS boosted_by_viewer".to_string(),
|
||||
};
|
||||
format!("
|
||||
SELECT
|
||||
t.id AS thought_id, t.user_id AS t_user_id, t.content,
|
||||
t.in_reply_to_id, t.in_reply_to_url, t.ap_id AS t_ap_id,
|
||||
@@ -60,8 +70,10 @@ const FEED_SELECT: &str = "
|
||||
u.created_at AS author_created_at, u.updated_at AS author_updated_at,
|
||||
(SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,
|
||||
(SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,
|
||||
(SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count
|
||||
FROM thoughts t JOIN users u ON u.id=t.user_id";
|
||||
(SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count,
|
||||
{viewer_checks}
|
||||
FROM thoughts t JOIN users u ON u.id=t.user_id")
|
||||
}
|
||||
|
||||
fn row_to_entry(r: FeedRow) -> FeedEntry {
|
||||
let thought = Thought {
|
||||
@@ -89,18 +101,20 @@ fn row_to_entry(r: FeedRow) -> FeedEntry {
|
||||
public_key: r.public_key, private_key: r.private_key,
|
||||
created_at: r.author_created_at, updated_at: r.author_updated_at,
|
||||
};
|
||||
FeedEntry { thought, author, like_count: r.like_count, boost_count: r.boost_count, reply_count: r.reply_count, liked_by_viewer: false, boosted_by_viewer: false }
|
||||
FeedEntry { thought, author, like_count: r.like_count, boost_count: r.boost_count, reply_count: r.reply_count, liked_by_viewer: r.liked_by_viewer, boosted_by_viewer: r.boosted_by_viewer }
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FeedRepository for PgFeedRepository {
|
||||
async fn home_feed(&self, following_ids: &[UserId], page: &PageParams, _viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
async fn home_feed(&self, following_ids: &[UserId], page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
let ids: Vec<uuid::Uuid> = following_ids.iter().map(|id| id.as_uuid()).collect();
|
||||
let viewer = viewer_id.map(|v| v.as_uuid());
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id=ANY($1) AND t.visibility='public'"
|
||||
).bind(&ids).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
let sql = format!("{FEED_SELECT} WHERE t.user_id=ANY($1) AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $2 OFFSET $3");
|
||||
let sel = feed_select(viewer);
|
||||
let sql = format!("{sel} WHERE t.user_id=ANY($1) AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $2 OFFSET $3");
|
||||
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||
.bind(&ids).bind(page.limit()).bind(page.offset())
|
||||
.fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
@@ -108,12 +122,14 @@ impl FeedRepository for PgFeedRepository {
|
||||
Ok(Paginated { items: rows.into_iter().map(row_to_entry).collect(), total, page: page.page, per_page: page.per_page })
|
||||
}
|
||||
|
||||
async fn public_feed(&self, page: &PageParams, _viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
async fn public_feed(&self, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
let viewer = viewer_id.map(|v| v.as_uuid());
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'"
|
||||
).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
let sql = format!("{FEED_SELECT} WHERE t.local=true AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $1 OFFSET $2");
|
||||
let sel = feed_select(viewer);
|
||||
let sql = format!("{sel} WHERE t.local=true AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $1 OFFSET $2");
|
||||
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||
.bind(page.limit()).bind(page.offset())
|
||||
.fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
@@ -121,7 +137,8 @@ impl FeedRepository for PgFeedRepository {
|
||||
Ok(Paginated { items: rows.into_iter().map(row_to_entry).collect(), total, page: page.page, per_page: page.per_page })
|
||||
}
|
||||
|
||||
async fn search(&self, query: &str, page: &PageParams, _viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
async fn search(&self, query: &str, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
let viewer = viewer_id.map(|v| v.as_uuid());
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'"
|
||||
)
|
||||
@@ -130,7 +147,8 @@ impl FeedRepository for PgFeedRepository {
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
let sql = format!("{FEED_SELECT} WHERE t.content % $1 AND t.visibility='public' ORDER BY similarity(t.content, $1) DESC LIMIT $2 OFFSET $3");
|
||||
let sel = feed_select(viewer);
|
||||
let sql = format!("{sel} WHERE t.content % $1 AND t.visibility='public' ORDER BY similarity(t.content, $1) DESC LIMIT $2 OFFSET $3");
|
||||
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||
.bind(query)
|
||||
.bind(page.limit())
|
||||
@@ -142,7 +160,8 @@ impl FeedRepository for PgFeedRepository {
|
||||
Ok(Paginated { items: rows.into_iter().map(row_to_entry).collect(), total, page: page.page, per_page: page.per_page })
|
||||
}
|
||||
|
||||
async fn tag_feed(&self, tag_name: &str, page: &PageParams, _viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
async fn tag_feed(&self, tag_name: &str, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
let viewer = viewer_id.map(|v| v.as_uuid());
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM thoughts t
|
||||
JOIN thought_tags tt ON tt.thought_id = t.id
|
||||
@@ -154,8 +173,9 @@ impl FeedRepository for PgFeedRepository {
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
let sel = feed_select(viewer);
|
||||
let sql = format!(
|
||||
"{FEED_SELECT}
|
||||
"{sel}
|
||||
JOIN thought_tags tt ON tt.thought_id = t.id
|
||||
JOIN tags tg ON tg.id = tt.tag_id
|
||||
WHERE tg.name = $1 AND t.visibility = 'public'
|
||||
@@ -176,6 +196,36 @@ impl FeedRepository for PgFeedRepository {
|
||||
per_page: page.per_page,
|
||||
})
|
||||
}
|
||||
|
||||
async fn user_feed(&self, user_id: &UserId, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
let viewer = viewer_id.map(|v| v.as_uuid());
|
||||
let uid = user_id.as_uuid();
|
||||
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id = $1 AND t.visibility = 'public'"
|
||||
)
|
||||
.bind(uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
let sel = feed_select(viewer);
|
||||
let sql = format!("{sel} WHERE t.user_id = $1 AND t.visibility = 'public' ORDER BY t.created_at DESC LIMIT $2 OFFSET $3");
|
||||
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||
.bind(uid)
|
||||
.bind(page.limit())
|
||||
.bind(page.offset())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
Ok(Paginated {
|
||||
items: rows.into_iter().map(row_to_entry).collect(),
|
||||
total,
|
||||
page: page.page,
|
||||
per_page: page.per_page,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -4,12 +4,11 @@ use sqlx::PgPool;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
feed::{FeedEntry, PageParams, Paginated},
|
||||
feed::{PageParams, Paginated},
|
||||
thought::{Thought, Visibility},
|
||||
user::User,
|
||||
},
|
||||
ports::ThoughtRepository,
|
||||
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
|
||||
value_objects::{Content, ThoughtId, UserId},
|
||||
};
|
||||
|
||||
pub struct PgThoughtRepository { pool: PgPool }
|
||||
@@ -119,47 +118,32 @@ impl ThoughtRepository for PgThoughtRepository {
|
||||
.map(|rows| rows.into_iter().map(Thought::from).collect())
|
||||
}
|
||||
|
||||
async fn list_by_user(&self, user_id: &UserId, page: &PageParams) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts WHERE user_id=$1")
|
||||
.bind(user_id.as_uuid())
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
async fn list_by_user(&self, user_id: &UserId, page: &PageParams) -> Result<Paginated<Thought>, DomainError> {
|
||||
let uid = user_id.as_uuid();
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM thoughts WHERE user_id = $1"
|
||||
)
|
||||
.bind(uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
let rows = sqlx::query_as::<_, ThoughtRow>(
|
||||
&format!("{THOUGHT_SELECT} WHERE user_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3")
|
||||
)
|
||||
.bind(user_id.as_uuid())
|
||||
.bind(uid)
|
||||
.bind(page.limit())
|
||||
.bind(page.offset())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
let author = sqlx::query_as::<_, crate::user::UserRow>(
|
||||
"SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,ap_id,inbox_url,public_key,private_key,created_at,updated_at FROM users WHERE id=$1"
|
||||
)
|
||||
.bind(user_id.as_uuid())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?
|
||||
.ok_or(DomainError::NotFound)?;
|
||||
let author = User::from(author);
|
||||
|
||||
let items = rows.into_iter().map(|r| {
|
||||
let thought = Thought::from(r);
|
||||
FeedEntry {
|
||||
thought,
|
||||
author: author.clone(),
|
||||
like_count: 0,
|
||||
boost_count: 0,
|
||||
reply_count: 0,
|
||||
liked_by_viewer: false,
|
||||
boosted_by_viewer: false,
|
||||
}
|
||||
}).collect();
|
||||
|
||||
Ok(Paginated { items, total, page: page.page, per_page: page.per_page })
|
||||
Ok(Paginated {
|
||||
items: rows.into_iter().map(Thought::from).collect(),
|
||||
total,
|
||||
page: page.page,
|
||||
per_page: page.per_page,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ use domain::{
|
||||
feed::{FeedEntry, PageParams, Paginated, UserSummary},
|
||||
user::User,
|
||||
},
|
||||
ports::{FeedRepository, FollowRepository, ThoughtRepository, UserRepository},
|
||||
ports::{FeedRepository, FollowRepository, TagRepository, UserRepository},
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
@@ -17,8 +17,8 @@ pub async fn get_public_feed(feed: &dyn FeedRepository, viewer_id: Option<&UserI
|
||||
feed.public_feed(&page, viewer_id).await
|
||||
}
|
||||
|
||||
pub async fn get_user_feed(thoughts: &dyn ThoughtRepository, user_id: &UserId, page: PageParams) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
thoughts.list_by_user(user_id, &page).await
|
||||
pub async fn get_user_feed(feed: &dyn FeedRepository, user_id: &UserId, page: PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
feed.user_feed(user_id, &page, viewer_id).await
|
||||
}
|
||||
|
||||
pub async fn get_followers(follows: &dyn FollowRepository, user_id: &UserId, page: PageParams) -> Result<Paginated<User>, DomainError> {
|
||||
@@ -29,8 +29,8 @@ pub async fn get_following(follows: &dyn FollowRepository, user_id: &UserId, pag
|
||||
follows.list_following(user_id, &page).await
|
||||
}
|
||||
|
||||
pub async fn get_by_tag(feed: &dyn FeedRepository, tag_name: &str, page: PageParams) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
feed.tag_feed(tag_name, &page, None).await
|
||||
pub async fn get_by_tag(feed: &dyn FeedRepository, tag_name: &str, page: PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
feed.tag_feed(tag_name, &page, viewer_id).await
|
||||
}
|
||||
|
||||
pub async fn search(feed: &dyn FeedRepository, query: &str, page: PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
@@ -40,3 +40,7 @@ pub async fn search(feed: &dyn FeedRepository, query: &str, page: PageParams, vi
|
||||
pub async fn list_users(users: &dyn UserRepository) -> Result<Vec<UserSummary>, DomainError> {
|
||||
users.list_with_stats().await
|
||||
}
|
||||
|
||||
pub async fn get_popular_tags(tags: &dyn TagRepository, limit: usize) -> Result<Vec<(String, i64)>, DomainError> {
|
||||
tags.popular_tags(limit).await
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
pub mod api_keys;
|
||||
pub mod auth;
|
||||
pub mod feed;
|
||||
pub mod notifications;
|
||||
pub mod profile;
|
||||
pub mod search;
|
||||
pub mod social;
|
||||
pub mod thoughts;
|
||||
|
||||
30
crates/application/src/use_cases/notifications.rs
Normal file
30
crates/application/src/use_cases/notifications.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::feed::{PageParams, Paginated},
|
||||
models::notification::Notification,
|
||||
ports::NotificationRepository,
|
||||
value_objects::{NotificationId, UserId},
|
||||
};
|
||||
|
||||
pub async fn list_notifications(
|
||||
repo: &dyn NotificationRepository,
|
||||
user_id: &UserId,
|
||||
page: PageParams,
|
||||
) -> Result<Paginated<Notification>, DomainError> {
|
||||
repo.list_for_user(user_id, &page).await
|
||||
}
|
||||
|
||||
pub async fn mark_notification_read(
|
||||
repo: &dyn NotificationRepository,
|
||||
id: &NotificationId,
|
||||
user_id: &UserId,
|
||||
) -> Result<(), DomainError> {
|
||||
repo.mark_read(id, user_id).await
|
||||
}
|
||||
|
||||
pub async fn mark_all_notifications_read(
|
||||
repo: &dyn NotificationRepository,
|
||||
user_id: &UserId,
|
||||
) -> Result<(), DomainError> {
|
||||
repo.mark_all_read(user_id).await
|
||||
}
|
||||
26
crates/application/src/use_cases/search.rs
Normal file
26
crates/application/src/use_cases/search.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
feed::{FeedEntry, PageParams, Paginated},
|
||||
user::User,
|
||||
},
|
||||
ports::SearchPort,
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
pub async fn search_thoughts(
|
||||
search: &dyn SearchPort,
|
||||
query: &str,
|
||||
page: PageParams,
|
||||
viewer_id: Option<&UserId>,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
search.search_thoughts(query, &page, viewer_id).await
|
||||
}
|
||||
|
||||
pub async fn search_users(
|
||||
search: &dyn SearchPort,
|
||||
query: &str,
|
||||
page: PageParams,
|
||||
) -> Result<Paginated<User>, DomainError> {
|
||||
search.search_users(query, &page).await
|
||||
}
|
||||
@@ -5,6 +5,14 @@ use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use activitypub_base::{
|
||||
actor_handler::actor_handler,
|
||||
followers_handler::{followers_handler, following_handler},
|
||||
inbox::inbox_handler,
|
||||
nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler},
|
||||
outbox::outbox_handler,
|
||||
webfinger::webfinger_handler,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
@@ -32,7 +40,19 @@ async fn main() {
|
||||
.allow_headers(tower_http::cors::Any)
|
||||
};
|
||||
|
||||
let base = presentation::routes::router(&infra.fed_config)
|
||||
let ap_router = axum::Router::new()
|
||||
.route("/.well-known/webfinger", axum::routing::get(webfinger_handler))
|
||||
.route("/.well-known/nodeinfo", axum::routing::get(nodeinfo_well_known_handler))
|
||||
.route("/nodeinfo/2.0", axum::routing::get(nodeinfo_handler))
|
||||
.route("/users/{username}", axum::routing::get(actor_handler))
|
||||
.route("/users/{username}/inbox", axum::routing::post(inbox_handler))
|
||||
.route("/users/{username}/outbox", axum::routing::get(outbox_handler))
|
||||
.route("/users/{username}/followers", axum::routing::get(followers_handler))
|
||||
.route("/users/{username}/following", axum::routing::get(following_handler))
|
||||
.layer(infra.fed_config.middleware());
|
||||
|
||||
let base = presentation::routes::router()
|
||||
.merge(ap_router)
|
||||
.with_state(infra.state)
|
||||
.layer(cors);
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ pub trait ThoughtRepository: Send + Sync {
|
||||
async fn delete(&self, id: &ThoughtId, user_id: &UserId) -> Result<(), DomainError>;
|
||||
async fn update_content(&self, id: &ThoughtId, content: &Content) -> Result<(), DomainError>;
|
||||
async fn get_thread(&self, id: &ThoughtId) -> Result<Vec<Thought>, DomainError>;
|
||||
async fn list_by_user(&self, user_id: &UserId, page: &PageParams) -> Result<Paginated<FeedEntry>, DomainError>;
|
||||
async fn list_by_user(&self, user_id: &UserId, page: &PageParams) -> Result<Paginated<Thought>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -139,6 +139,7 @@ pub trait FeedRepository: Send + Sync {
|
||||
async fn public_feed(&self, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError>;
|
||||
async fn search(&self, query: &str, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError>;
|
||||
async fn tag_feed(&self, tag_name: &str, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError>;
|
||||
async fn user_feed(&self, user_id: &UserId, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -96,7 +96,7 @@ pub struct TestStore {
|
||||
.filter(|t| t.in_reply_to_id.as_ref() == Some(id) || &t.id == id)
|
||||
.cloned().collect())
|
||||
}
|
||||
async fn list_by_user(&self, _user_id: &UserId, _page: &PageParams) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
async fn list_by_user(&self, _user_id: &UserId, _page: &PageParams) -> Result<Paginated<Thought>, DomainError> {
|
||||
Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 })
|
||||
}
|
||||
}
|
||||
@@ -290,6 +290,9 @@ pub struct TestStore {
|
||||
async fn tag_feed(&self, _tag_name: &str, _page: &PageParams, _viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 })
|
||||
}
|
||||
async fn user_feed(&self, _user_id: &UserId, _page: &PageParams, _viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 })
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait] impl SearchPort for TestStore {
|
||||
|
||||
@@ -18,9 +18,7 @@ tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
activitypub-base = { workspace = true }
|
||||
url = { workspace = true }
|
||||
activitypub_federation = "0.7.0-beta.11"
|
||||
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid", "chrono"] }
|
||||
utoipa-scalar = { version = "0.3.0", features = ["axum"], default-features = false }
|
||||
utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] }
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use axum::{extract::{Path, Query, State}, Json};
|
||||
use api_types::requests::{PaginationQuery, SearchQuery};
|
||||
use api_types::responses::ThoughtResponse;
|
||||
use application::use_cases::feed::{get_home_feed, get_public_feed, get_followers, get_following, get_user_feed, get_by_tag};
|
||||
use application::use_cases::feed::{get_home_feed, get_public_feed, get_followers, get_following, get_user_feed, get_by_tag, get_popular_tags as uc_get_popular_tags};
|
||||
use application::use_cases::search::{search_thoughts, search_users};
|
||||
use domain::models::feed::PageParams;
|
||||
use crate::{errors::ApiError, extractors::{AuthUser, OptionalAuthUser}, handlers::auth::to_user_response, state::AppState};
|
||||
use application::use_cases::profile::get_user_by_username;
|
||||
@@ -72,8 +73,8 @@ pub async fn search_handler(
|
||||
let query = q.q.trim().to_string();
|
||||
|
||||
let (thoughts_result, users_result) = tokio::join!(
|
||||
s.search.search_thoughts(&query, &page, viewer.as_ref()),
|
||||
s.search.search_users(&query, &page),
|
||||
search_thoughts(&*s.search, &query, PageParams { page: page.page, per_page: page.per_page }, viewer.as_ref()),
|
||||
search_users(&*s.search, &query, PageParams { page: page.page, per_page: page.per_page }),
|
||||
);
|
||||
|
||||
let thoughts = thoughts_result?.items.into_iter().map(|e| serde_json::json!({
|
||||
@@ -120,11 +121,12 @@ pub async fn get_followers_handler(State(s): State<AppState>, Path(username): Pa
|
||||
pub async fn user_thoughts_handler(
|
||||
State(s): State<AppState>,
|
||||
Path(username): Path<String>,
|
||||
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||
Query(q): Query<PaginationQuery>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let user = get_user_by_username(&*s.users, &username).await?;
|
||||
let page = PageParams { page: q.page(), per_page: q.per_page() };
|
||||
let result = get_user_feed(&*s.thoughts, &user.id, page).await?;
|
||||
let result = get_user_feed(&*s.feed, &user.id, page, viewer.as_ref()).await?;
|
||||
Ok(Json(serde_json::json!({
|
||||
"total": result.total,
|
||||
"page": result.page,
|
||||
@@ -138,7 +140,7 @@ pub async fn get_popular_tags(
|
||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let limit: usize = params.get("limit").and_then(|v| v.parse().ok()).unwrap_or(20);
|
||||
let tags = s.tags.popular_tags(limit.min(100)).await?;
|
||||
let tags = uc_get_popular_tags(&*s.tags, limit.min(100)).await?;
|
||||
Ok(Json(serde_json::json!({
|
||||
"tags": tags.iter().map(|(name, count)| serde_json::json!({
|
||||
"name": name,
|
||||
@@ -158,10 +160,11 @@ pub async fn get_popular_tags(
|
||||
pub async fn tag_thoughts_handler(
|
||||
State(s): State<AppState>,
|
||||
Path(tag_name): Path<String>,
|
||||
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||
Query(q): Query<PaginationQuery>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let page = PageParams { page: q.page(), per_page: q.per_page() };
|
||||
let result = get_by_tag(&*s.feed, &tag_name, page).await?;
|
||||
let result = get_by_tag(&*s.feed, &tag_name, page, viewer.as_ref()).await?;
|
||||
Ok(Json(serde_json::json!({
|
||||
"tag": tag_name,
|
||||
"total": result.total,
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
use axum::{extract::{Path, State}, http::StatusCode, Json};
|
||||
use uuid::Uuid;
|
||||
use domain::{models::feed::PageParams, value_objects::NotificationId};
|
||||
use application::use_cases::notifications::{
|
||||
list_notifications as uc_list_notifications,
|
||||
mark_notification_read as uc_mark_notification_read,
|
||||
mark_all_notifications_read,
|
||||
};
|
||||
use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
|
||||
|
||||
#[utoipa::path(get, path = "/notifications", responses((status = 200, description = "Notification summary")), security(("bearer_auth" = [])))]
|
||||
pub async fn list_notifications(State(s): State<AppState>, AuthUser(uid): AuthUser) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let page = PageParams { page: 1, per_page: 20 };
|
||||
let result = s.notifications.list_for_user(&uid, &page).await?;
|
||||
let result = uc_list_notifications(&*s.notifications, &uid, page).await?;
|
||||
Ok(Json(serde_json::json!({ "total": result.total, "unread": result.items.iter().filter(|n| !n.read).count() })))
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/notifications/{id}/read", params(("id" = uuid::Uuid, Path, description = "Notification ID")), responses((status = 204, description = "Marked read")), security(("bearer_auth" = [])))]
|
||||
pub async fn mark_notification_read(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(id): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
||||
s.notifications.mark_read(&NotificationId::from_uuid(id), &uid).await?;
|
||||
uc_mark_notification_read(&*s.notifications, &NotificationId::from_uuid(id), &uid).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/notifications/read-all", responses((status = 204, description = "All marked read")), security(("bearer_auth" = [])))]
|
||||
pub async fn mark_all_read(State(s): State<AppState>, AuthUser(uid): AuthUser) -> Result<StatusCode, ApiError> {
|
||||
s.notifications.mark_all_read(&uid).await?;
|
||||
mark_all_notifications_read(&*s.notifications, &uid).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use axum::{extract::{Path, Query, State}, Json};
|
||||
use api_types::{requests::UpdateProfileRequest, responses::{ErrorResponse, UserResponse}};
|
||||
use application::use_cases::profile::{get_user_by_username, update_profile};
|
||||
use application::use_cases::search::search_users;
|
||||
use application::use_cases::feed::list_users;
|
||||
use crate::{errors::ApiError, extractors::AuthUser, handlers::auth::to_user_response, state::AppState};
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -54,14 +56,14 @@ pub async fn get_users(
|
||||
let page_params = PageParams { page, per_page };
|
||||
|
||||
if let Some(q) = params.get("q").filter(|q| !q.trim().is_empty()) {
|
||||
let result = s.search.search_users(q, &page_params).await?;
|
||||
let result = search_users(&*s.search, q, page_params).await?;
|
||||
let users: Vec<_> = result.items.iter().map(|u| crate::handlers::auth::to_user_response(u)).collect();
|
||||
return Ok(Json(serde_json::json!({
|
||||
"items": users, "total": result.total, "page": result.page, "per_page": result.per_page
|
||||
})));
|
||||
}
|
||||
|
||||
let all = s.users.list_with_stats().await?;
|
||||
let all = list_users(&*s.users).await?;
|
||||
let total = all.len() as i64;
|
||||
let start = ((page - 1) * per_page) as usize;
|
||||
let items: Vec<_> = all.into_iter()
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
use axum::{
|
||||
routing::{delete, get, patch, post, put},
|
||||
routing::{delete, get, post, put},
|
||||
Router,
|
||||
};
|
||||
use activitypub_base::{
|
||||
actor_handler::actor_handler,
|
||||
followers_handler::{followers_handler, following_handler},
|
||||
inbox::inbox_handler,
|
||||
nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler},
|
||||
outbox::outbox_handler,
|
||||
webfinger::webfinger_handler,
|
||||
ApFederationConfig,
|
||||
};
|
||||
use activitypub_federation::config::FederationMiddleware;
|
||||
use crate::{handlers::*, openapi, state::AppState};
|
||||
|
||||
pub fn router(fed_config: &ApFederationConfig) -> Router<AppState> {
|
||||
pub fn router() -> Router<AppState> {
|
||||
let api_routes = Router::new()
|
||||
// health
|
||||
.route("/health", get(health::health_handler))
|
||||
@@ -74,20 +64,5 @@ pub fn router(fed_config: &ApFederationConfig) -> Router<AppState> {
|
||||
)
|
||||
.route("/api-keys/{id}", delete(api_keys::delete_api_key_handler));
|
||||
|
||||
let ap_routes = Router::new()
|
||||
.route("/.well-known/webfinger", get(webfinger_handler))
|
||||
.route("/.well-known/nodeinfo", get(nodeinfo_well_known_handler))
|
||||
.route("/nodeinfo/2.0", get(nodeinfo_handler))
|
||||
.route("/users/{username}", get(actor_handler))
|
||||
.route("/users/{username}/inbox", post(inbox_handler))
|
||||
.route("/users/{username}/outbox", get(outbox_handler))
|
||||
.route("/users/{username}/followers", get(followers_handler))
|
||||
.route("/users/{username}/following", get(following_handler));
|
||||
|
||||
let combined = Router::new()
|
||||
.merge(api_routes)
|
||||
.merge(ap_routes)
|
||||
.layer(FederationMiddleware::new(fed_config.0.clone()));
|
||||
|
||||
openapi::serve(combined)
|
||||
openapi::serve(api_routes)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user