From 0d43d0adb962a400cd69a8bb6d220f53cabf9fe4 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 16 May 2026 02:46:20 +0200 Subject: [PATCH 1/4] refactor(ap-ports): accept_note returns ThoughtId instead of () --- crates/adapters/activitypub-base/src/ap_ports.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/adapters/activitypub-base/src/ap_ports.rs b/crates/adapters/activitypub-base/src/ap_ports.rs index 5f389f4..15190c0 100644 --- a/crates/adapters/activitypub-base/src/ap_ports.rs +++ b/crates/adapters/activitypub-base/src/ap_ports.rs @@ -72,7 +72,7 @@ pub trait ActivityPubRepository: Send + Sync { content_warning: Option, visibility: &str, in_reply_to: Option<&str>, - ) -> Result<(), DomainError>; + ) -> Result; /// Apply an Update to a previously accepted remote Note. async fn apply_note_update(&self, ap_id: &str, new_content: &str) -> Result<(), DomainError>; From 98e96b306a8b46e994e6b6149961c916cfe523fa Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 16 May 2026 02:48:52 +0200 Subject: [PATCH 2/4] fix(postgres): accept_note returns ThoughtId via SELECT after INSERT --- crates/adapters/postgres/src/activitypub.rs | 44 +++++++++++++++++++-- crates/application/src/testing.rs | 4 +- crates/presentation/src/testing.rs | 4 +- 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/crates/adapters/postgres/src/activitypub.rs b/crates/adapters/postgres/src/activitypub.rs index 67050f4..39bb1ab 100644 --- a/crates/adapters/postgres/src/activitypub.rs +++ b/crates/adapters/postgres/src/activitypub.rs @@ -220,7 +220,7 @@ impl ActivityPubRepository for PgActivityPubRepository { content_warning: Option, visibility: &str, in_reply_to: Option<&str>, - ) -> Result<(), DomainError> { + ) -> Result { let capped: String = content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect(); let (in_reply_to_id, in_reply_to_url) = match in_reply_to { Some(url) => { @@ -251,8 +251,16 @@ impl ActivityPubRepository for PgActivityPubRepository { .bind(&in_reply_to_url) .execute(&self.pool) .await - .into_domain() - .map(|_| ()) + .into_domain()?; + + // SELECT the id — works whether the INSERT was a no-op or not (idempotent). + let row: (uuid::Uuid,) = + sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1") + .bind(ap_id) + .fetch_one(&self.pool) + .await + .into_domain()?; + Ok(ThoughtId::from_uuid(row.0)) } async fn apply_note_update(&self, ap_id: &str, new_content: &str) -> Result<(), DomainError> { @@ -365,4 +373,34 @@ mod tests { let repo = PgActivityPubRepository::new(pool); assert_eq!(repo.count_local_notes().await.unwrap(), 0); } + + #[sqlx::test(migrations = "./migrations")] + async fn accept_note_returns_thought_id(pool: sqlx::PgPool) { + let repo = PgActivityPubRepository::new(pool.clone()); + let actor_user_id = repo + .intern_remote_actor("https://remote.example/users/alice") + .await + .unwrap(); + + let thought_id = repo + .accept_note( + "https://remote.example/notes/1", + &actor_user_id, + "Hello #rust world", + chrono::Utc::now(), + false, + None, + "public", + None, + ) + .await + .unwrap(); + + let row: (uuid::Uuid,) = sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1") + .bind("https://remote.example/notes/1") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(thought_id.as_uuid(), row.0); + } } diff --git a/crates/application/src/testing.rs b/crates/application/src/testing.rs index 689289e..b35a40c 100644 --- a/crates/application/src/testing.rs +++ b/crates/application/src/testing.rs @@ -103,8 +103,8 @@ impl ActivityPubRepository for TestApRepo { _content_warning: Option, _visibility: &str, _in_reply_to: Option<&str>, - ) -> Result<(), DomainError> { - Ok(()) + ) -> Result { + Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4())) } async fn apply_note_update( &self, diff --git a/crates/presentation/src/testing.rs b/crates/presentation/src/testing.rs index 1e801f9..b1bc505 100644 --- a/crates/presentation/src/testing.rs +++ b/crates/presentation/src/testing.rs @@ -76,8 +76,8 @@ impl ActivityPubRepository for NoOpApRepo { _content_warning: Option, _visibility: &str, _in_reply_to: Option<&str>, - ) -> Result<(), DomainError> { - Ok(()) + ) -> Result { + Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4())) } async fn apply_note_update( &self, From 3907ee1538161c8e6065115ce7333a10dfe4fdc6 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 16 May 2026 02:51:09 +0200 Subject: [PATCH 3/4] feat(activitypub): index hashtags from incoming federated notes --- crates/adapters/activitypub/src/handler.rs | 23 ++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/adapters/activitypub/src/handler.rs b/crates/adapters/activitypub/src/handler.rs index faaa5d3..a2635bb 100644 --- a/crates/adapters/activitypub/src/handler.rs +++ b/crates/adapters/activitypub/src/handler.rs @@ -10,13 +10,14 @@ use url::Url; use crate::note::ThoughtNote; use crate::urls::ThoughtsUrls; use activitypub_base::{ActivityPubRepository, ApObjectHandler}; -use domain::ports::EventPublisher; +use domain::ports::{EventPublisher, TagRepository}; use domain::value_objects::UserId; pub struct ThoughtsObjectHandler { repo: Arc, urls: ThoughtsUrls, event_publisher: Option>, + tag_repo: Arc, } impl ThoughtsObjectHandler { @@ -24,11 +25,13 @@ impl ThoughtsObjectHandler { repo: Arc, base_url: &str, event_publisher: Option>, + tag_repo: Arc, ) -> Self { Self { repo, urls: ThoughtsUrls::new(base_url), event_publisher, + tag_repo, } } } @@ -138,7 +141,7 @@ impl ApObjectHandler for ThoughtsObjectHandler { "direct" }; - self.repo + let thought_id = self.repo .accept_note( ap_id.as_str(), &author_id, @@ -152,6 +155,22 @@ impl ApObjectHandler for ThoughtsObjectHandler { .await .map_err(|e| anyhow!("{e}"))?; + // Extract and index hashtags from the AP tag array. + let hashtag_names: Vec = note + .tag + .iter() + .filter(|t| t.get("type").and_then(|v| v.as_str()) == Some("Hashtag")) + .filter_map(|t| t.get("name").and_then(|v| v.as_str())) + .map(|name| name.trim_start_matches('#').to_lowercase()) + .filter(|name| !name.is_empty()) + .collect(); + + for name in hashtag_names { + if let Ok(tag) = self.tag_repo.find_or_create(&name).await { + let _ = self.tag_repo.attach_to_thought(&thought_id, tag.id).await; + } + } + // Fire mention notifications for local @mentions in the note's tag array. let base_url = url::Url::parse(&self.urls.base_url) .ok() From efa9bbc6e5ea755ceb4152fea85555f6f844354a Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 16 May 2026 02:52:58 +0200 Subject: [PATCH 4/4] feat(bootstrap): inject TagRepository into ThoughtsObjectHandler --- crates/bootstrap/src/factory.rs | 1 + crates/worker/src/factory.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/crates/bootstrap/src/factory.rs b/crates/bootstrap/src/factory.rs index a0e7fe3..55306a6 100644 --- a/crates/bootstrap/src/factory.rs +++ b/crates/bootstrap/src/factory.rs @@ -77,6 +77,7 @@ pub async fn build(cfg: &Config) -> Infrastructure { Arc::new(PgActivityPubRepository::new(pool.clone())), &cfg.base_url, Some(event_publisher.clone()), + Arc::new(postgres::tag::PgTagRepository::new(pool.clone())), )), cfg.base_url.clone(), cfg.allow_registration, diff --git a/crates/worker/src/factory.rs b/crates/worker/src/factory.rs index 95b279c..bc43891 100644 --- a/crates/worker/src/factory.rs +++ b/crates/worker/src/factory.rs @@ -49,6 +49,7 @@ pub async fn build(database_url: &str, base_url: &str, nats_url: &str) -> Worker Arc::new(PgActivityPubRepository::new(pool.clone())), base_url, None, + Arc::new(postgres::tag::PgTagRepository::new(pool.clone())), )), base_url.to_string(), false,