clean up
This commit is contained in:
@@ -1,639 +0,0 @@
|
||||
# ActivityPubRepository Port Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Eliminate the `activitypub` → `postgres` dependency violation by extracting an `ActivityPubRepository` port into domain and implementing it in the postgres adapter.
|
||||
|
||||
**Architecture:** `ActivityPubRepository` (9 methods, federation vocabulary) is added to `domain/src/ports.rs`. `PgActivityPubRepository` in `postgres/src/activitypub.rs` implements it with all the SQL that currently lives in `activitypub/src/handler.rs`. `ThoughtsObjectHandler` drops its `PgPool` and receives `Arc<dyn ActivityPubRepository>` instead. The dependency chain becomes `activitypub → domain` only; `postgres` drops off the `activitypub` Cargo.toml entirely.
|
||||
|
||||
**Tech Stack:** Rust, sqlx 0.8, async-trait, existing domain value objects
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
```
|
||||
Modify: crates/domain/src/ports.rs ← add OutboxEntry struct + ActivityPubRepository trait
|
||||
Modify: crates/domain/src/testing.rs ← add TestStore impl ActivityPubRepository
|
||||
Create: crates/adapters/postgres/src/activitypub.rs ← PgActivityPubRepository (all 9 methods)
|
||||
Modify: crates/adapters/postgres/src/lib.rs ← pub mod activitypub
|
||||
Modify: crates/adapters/activitypub/src/handler.rs ← replace PgPool with Arc<dyn ActivityPubRepository>
|
||||
Modify: crates/adapters/activitypub/Cargo.toml ← remove postgres + sqlx deps
|
||||
Modify: crates/presentation/src/lib.rs ← wire PgActivityPubRepository into ThoughtsObjectHandler
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Domain — OutboxEntry + ActivityPubRepository trait
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/domain/src/ports.rs`
|
||||
- Modify: `crates/domain/src/testing.rs`
|
||||
|
||||
- [ ] **Write the failing test** — add to bottom of `crates/domain/src/testing.rs` inside the existing `#[cfg(any(test, feature = "test-helpers"))]` scope:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod ap_repo_tests {
|
||||
use super::*;
|
||||
use crate::models::thought::{Thought, Visibility};
|
||||
use crate::value_objects::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_outbox_returns_empty() {
|
||||
let store = TestStore::default();
|
||||
let result = store.outbox_entries_for_actor(&UserId::new()).await.unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_intern_creates_placeholder() {
|
||||
let store = TestStore::default();
|
||||
let url = url::Url::parse("https://example.com/users/alice").unwrap();
|
||||
let id1 = store.intern_remote_actor(&url).await.unwrap();
|
||||
let id2 = store.intern_remote_actor(&url).await.unwrap();
|
||||
assert_eq!(id1, id2, "intern must be idempotent");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo test -p domain` — Expected: FAIL (ActivityPubRepository not defined).
|
||||
|
||||
- [ ] **Add `OutboxEntry` and `ActivityPubRepository` to `crates/domain/src/ports.rs`** — append after the `SearchPort` trait:
|
||||
|
||||
```rust
|
||||
/// A local thought ready for AP serialization, with the author's username
|
||||
/// pre-joined so the handler can build AP URLs without a second query.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OutboxEntry {
|
||||
pub thought: crate::models::thought::Thought,
|
||||
pub author_username: Username,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ActivityPubRepository: Send + Sync {
|
||||
// ── Outbox (local → remote) ──────────────────────────────────────
|
||||
|
||||
/// All public local thoughts for this actor. Used for outbox totals
|
||||
/// and full-collection delivery.
|
||||
async fn outbox_entries_for_actor(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<OutboxEntry>, DomainError>;
|
||||
|
||||
/// Cursor page of public local thoughts, newest-first, stopping before
|
||||
/// `before`. Used for OrderedCollectionPage responses.
|
||||
async fn outbox_page_for_actor(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
before: Option<chrono::DateTime<chrono::Utc>>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<OutboxEntry>, DomainError>;
|
||||
|
||||
// ── Remote actor resolution ──────────────────────────────────────
|
||||
|
||||
/// Find the local UserId for a remote actor by its AP URL.
|
||||
async fn find_remote_actor_id(
|
||||
&self,
|
||||
actor_ap_url: &url::Url,
|
||||
) -> Result<Option<UserId>, DomainError>;
|
||||
|
||||
/// Ensure a remote actor placeholder exists; create one if absent.
|
||||
/// Idempotent — safe to call multiple times with the same URL.
|
||||
async fn intern_remote_actor(
|
||||
&self,
|
||||
actor_ap_url: &url::Url,
|
||||
) -> Result<UserId, DomainError>;
|
||||
|
||||
// ── Inbox processing (remote → local) ───────────────────────────
|
||||
|
||||
/// Persist an incoming remote Note. Idempotent on ap_id.
|
||||
async fn accept_note(
|
||||
&self,
|
||||
ap_id: &url::Url,
|
||||
author_id: &UserId,
|
||||
content: &str,
|
||||
published: chrono::DateTime<chrono::Utc>,
|
||||
sensitive: bool,
|
||||
content_warning: Option<String>,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Apply an Update to a previously accepted remote Note.
|
||||
async fn apply_note_update(
|
||||
&self,
|
||||
ap_id: &url::Url,
|
||||
new_content: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Remove a specific remote Note (Delete activity). Only touches
|
||||
/// remotely-originated thoughts.
|
||||
async fn retract_note(&self, ap_id: &url::Url) -> Result<(), DomainError>;
|
||||
|
||||
/// Remove all Notes from a remote actor (actor-level Delete/Tombstone).
|
||||
async fn retract_actor_notes(
|
||||
&self,
|
||||
actor_ap_url: &url::Url,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
// ── Node-level stats ─────────────────────────────────────────────
|
||||
|
||||
/// Total locally-authored thought count for NodeInfo responses.
|
||||
async fn count_local_notes(&self) -> Result<u64, DomainError>;
|
||||
}
|
||||
```
|
||||
|
||||
The imports already present in `ports.rs` cover `DomainError`, `UserId`, `Username`, `async_trait`. The `url::Url` and `chrono::DateTime` types need to be in scope — add these use statements at the top of `ports.rs` if not already present:
|
||||
|
||||
```rust
|
||||
use chrono::{DateTime, Utc};
|
||||
use url::Url;
|
||||
```
|
||||
|
||||
Note: `url` and `chrono` are already in `domain/Cargo.toml`. No dep changes needed.
|
||||
|
||||
- [ ] **Add `TestStore impl ActivityPubRepository`** in `crates/domain/src/testing.rs` — append after `impl SearchPort for TestStore`:
|
||||
|
||||
```rust
|
||||
#[async_trait] impl ActivityPubRepository for TestStore {
|
||||
async fn outbox_entries_for_actor(&self, _uid: &UserId) -> Result<Vec<crate::ports::OutboxEntry>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn outbox_page_for_actor(&self, _uid: &UserId, _before: Option<chrono::DateTime<chrono::Utc>>, _limit: usize) -> Result<Vec<crate::ports::OutboxEntry>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn find_remote_actor_id(&self, actor_ap_url: &url::Url) -> Result<Option<UserId>, DomainError> {
|
||||
let url = actor_ap_url.to_string();
|
||||
Ok(self.users.lock().unwrap().iter().find(|u| u.ap_id.as_deref() == Some(&url)).map(|u| u.id.clone()))
|
||||
}
|
||||
async fn intern_remote_actor(&self, actor_ap_url: &url::Url) -> Result<UserId, DomainError> {
|
||||
if let Some(uid) = self.find_remote_actor_id(actor_ap_url).await? {
|
||||
return Ok(uid);
|
||||
}
|
||||
let uid = UserId::new();
|
||||
let handle = actor_ap_url.path().trim_start_matches('/').replace('/', "_");
|
||||
let user = crate::models::user::User {
|
||||
id: uid.clone(),
|
||||
username: Username::from_trusted(handle.clone()),
|
||||
email: Email::from_trusted(format!("{}@remote", uid)),
|
||||
password_hash: PasswordHash("".into()),
|
||||
display_name: None, bio: None, avatar_url: None, header_url: None,
|
||||
custom_css: None, local: false,
|
||||
ap_id: Some(actor_ap_url.to_string()),
|
||||
inbox_url: None, public_key: None, private_key: None,
|
||||
created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(),
|
||||
};
|
||||
self.users.lock().unwrap().push(user);
|
||||
Ok(uid)
|
||||
}
|
||||
async fn accept_note(&self, _ap_id: &url::Url, _author_id: &UserId, _content: &str, _published: chrono::DateTime<chrono::Utc>, _sensitive: bool, _content_warning: Option<String>) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn apply_note_update(&self, _ap_id: &url::Url, _new_content: &str) -> Result<(), DomainError> { Ok(()) }
|
||||
async fn retract_note(&self, _ap_id: &url::Url) -> Result<(), DomainError> { Ok(()) }
|
||||
async fn retract_actor_notes(&self, _actor_ap_url: &url::Url) -> Result<(), DomainError> { Ok(()) }
|
||||
async fn count_local_notes(&self) -> Result<u64, DomainError> {
|
||||
Ok(self.thoughts.lock().unwrap().iter().filter(|t| t.local).count() as u64)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo test -p domain` — Expected: all tests pass including 2 new ap_repo tests.
|
||||
|
||||
- [ ] **Commit:**
|
||||
```bash
|
||||
git add crates/domain/
|
||||
git commit -m "feat(domain): ActivityPubRepository port with federation vocabulary"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Postgres — PgActivityPubRepository
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/adapters/postgres/src/activitypub.rs`
|
||||
- Modify: `crates/adapters/postgres/src/lib.rs`
|
||||
|
||||
- [ ] **Write integration tests** at the bottom of the new `crates/adapters/postgres/src/activitypub.rs` (create the file with tests first):
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::ports::ActivityPubRepository;
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) {
|
||||
let repo = PgActivityPubRepository::new(pool);
|
||||
let url = url::Url::parse("https://mastodon.social/users/alice").unwrap();
|
||||
let id1 = repo.intern_remote_actor(&url).await.unwrap();
|
||||
let id2 = repo.intern_remote_actor(&url).await.unwrap();
|
||||
assert_eq!(id1, id2);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn accept_and_retract_note(pool: sqlx::PgPool) {
|
||||
let repo = PgActivityPubRepository::new(pool);
|
||||
let actor_url = url::Url::parse("https://remote.example/users/bob").unwrap();
|
||||
let ap_id = url::Url::parse("https://remote.example/notes/1").unwrap();
|
||||
let author = repo.intern_remote_actor(&actor_url).await.unwrap();
|
||||
repo.accept_note(&ap_id, &author, "hello from remote", chrono::Utc::now(), false, None)
|
||||
.await.unwrap();
|
||||
repo.retract_note(&ap_id).await.unwrap();
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn count_local_notes_excludes_remote(pool: sqlx::PgPool) {
|
||||
let repo = PgActivityPubRepository::new(pool);
|
||||
assert_eq!(repo.count_local_notes().await.unwrap(), 0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo test -p postgres activitypub` — Expected: FAIL (module does not exist).
|
||||
|
||||
- [ ] **Write `crates/adapters/postgres/src/activitypub.rs`:**
|
||||
|
||||
```rust
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use url::Url;
|
||||
|
||||
use domain::{errors::DomainError, ports::{ActivityPubRepository, OutboxEntry}, value_objects::{Content, ThoughtId, UserId, Username}};
|
||||
use domain::models::thought::{Thought, Visibility};
|
||||
|
||||
pub struct PgActivityPubRepository { pool: PgPool }
|
||||
impl PgActivityPubRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||
|
||||
#[async_trait]
|
||||
impl ActivityPubRepository for PgActivityPubRepository {
|
||||
async fn outbox_entries_for_actor(&self, user_id: &UserId) -> Result<Vec<OutboxEntry>, DomainError> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row { id: uuid::Uuid, user_id: uuid::Uuid, content: String, created_at: DateTime<Utc>, in_reply_to_id: Option<uuid::Uuid>, content_warning: Option<String>, sensitive: bool, username: String, updated_at: Option<DateTime<Utc>> }
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
|
||||
FROM thoughts t JOIN users u ON u.id=t.user_id
|
||||
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'
|
||||
ORDER BY t.created_at DESC"
|
||||
)
|
||||
.bind(user_id.as_uuid())
|
||||
.fetch_all(&self.pool).await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
.map(|rows| rows.into_iter().map(|r| OutboxEntry {
|
||||
thought: Thought {
|
||||
id: ThoughtId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id),
|
||||
content: Content::new_remote(r.content), in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
|
||||
in_reply_to_url: None, ap_id: None, visibility: Visibility::Public,
|
||||
content_warning: r.content_warning, sensitive: r.sensitive, local: true,
|
||||
created_at: r.created_at, updated_at: r.updated_at,
|
||||
},
|
||||
author_username: Username::from_trusted(r.username),
|
||||
}).collect())
|
||||
}
|
||||
|
||||
async fn outbox_page_for_actor(&self, user_id: &UserId, before: Option<DateTime<Utc>>, limit: usize) -> Result<Vec<OutboxEntry>, DomainError> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row { id: uuid::Uuid, user_id: uuid::Uuid, content: String, created_at: DateTime<Utc>, in_reply_to_id: Option<uuid::Uuid>, content_warning: Option<String>, sensitive: bool, username: String, updated_at: Option<DateTime<Utc>> }
|
||||
let rows = if let Some(before) = before {
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
|
||||
FROM thoughts t JOIN users u ON u.id=t.user_id
|
||||
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public' AND t.created_at < $2
|
||||
ORDER BY t.created_at DESC LIMIT $3"
|
||||
).bind(user_id.as_uuid()).bind(before).bind(limit as i64).fetch_all(&self.pool).await
|
||||
} else {
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
|
||||
FROM thoughts t JOIN users u ON u.id=t.user_id
|
||||
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'
|
||||
ORDER BY t.created_at DESC LIMIT $2"
|
||||
).bind(user_id.as_uuid()).bind(limit as i64).fetch_all(&self.pool).await
|
||||
}.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
Ok(rows.into_iter().map(|r| OutboxEntry {
|
||||
thought: Thought {
|
||||
id: ThoughtId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id),
|
||||
content: Content::new_remote(r.content), in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
|
||||
in_reply_to_url: None, ap_id: None, visibility: Visibility::Public,
|
||||
content_warning: r.content_warning, sensitive: r.sensitive, local: true,
|
||||
created_at: r.created_at, updated_at: r.updated_at,
|
||||
},
|
||||
author_username: Username::from_trusted(r.username),
|
||||
}).collect())
|
||||
}
|
||||
|
||||
async fn find_remote_actor_id(&self, actor_ap_url: &Url) -> Result<Option<UserId>, DomainError> {
|
||||
sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM users WHERE ap_id=$1")
|
||||
.bind(actor_ap_url.as_str())
|
||||
.fetch_optional(&self.pool).await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
.map(|o| o.map(UserId::from_uuid))
|
||||
}
|
||||
|
||||
async fn intern_remote_actor(&self, actor_ap_url: &Url) -> Result<UserId, DomainError> {
|
||||
// Fast path
|
||||
if let Some(id) = self.find_remote_actor_id(actor_ap_url).await? {
|
||||
return Ok(id);
|
||||
}
|
||||
let new_id = uuid::Uuid::new_v4();
|
||||
let handle = actor_ap_url.path().trim_start_matches('/').replace('/', "_");
|
||||
sqlx::query(
|
||||
"INSERT INTO users(id,username,email,password_hash,local,ap_id,created_at,updated_at)
|
||||
VALUES($1,$2,$3,'',false,$4,NOW(),NOW()) ON CONFLICT(ap_id) DO NOTHING"
|
||||
)
|
||||
.bind(new_id).bind(&handle).bind(format!("{}@remote", new_id)).bind(actor_ap_url.as_str())
|
||||
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
// Re-fetch to get whichever id won the race
|
||||
self.find_remote_actor_id(actor_ap_url).await?
|
||||
.ok_or_else(|| DomainError::Internal("intern_remote_actor: insert succeeded but row not found".into()))
|
||||
}
|
||||
|
||||
async fn accept_note(&self, ap_id: &Url, author_id: &UserId, content: &str, published: DateTime<Utc>, sensitive: bool, content_warning: Option<String>) -> Result<(), DomainError> {
|
||||
let capped: String = content.chars().take(500).collect();
|
||||
sqlx::query(
|
||||
"INSERT INTO thoughts(id,user_id,content,ap_id,visibility,sensitive,local,content_warning,created_at)
|
||||
VALUES($1,$2,$3,$4,'public',$5,false,$6,$7) ON CONFLICT(ap_id) DO NOTHING"
|
||||
)
|
||||
.bind(uuid::Uuid::new_v4()).bind(author_id.as_uuid()).bind(&capped)
|
||||
.bind(ap_id.as_str()).bind(sensitive).bind(content_warning).bind(published)
|
||||
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
|
||||
}
|
||||
|
||||
async fn apply_note_update(&self, ap_id: &Url, new_content: &str) -> Result<(), DomainError> {
|
||||
let capped: String = new_content.chars().take(500).collect();
|
||||
sqlx::query("UPDATE thoughts SET content=$2,updated_at=NOW() WHERE ap_id=$1 AND local=false")
|
||||
.bind(ap_id.as_str()).bind(&capped)
|
||||
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
|
||||
}
|
||||
|
||||
async fn retract_note(&self, ap_id: &Url) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM thoughts WHERE ap_id=$1 AND local=false")
|
||||
.bind(ap_id.as_str())
|
||||
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
|
||||
}
|
||||
|
||||
async fn retract_actor_notes(&self, actor_ap_url: &Url) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"DELETE FROM thoughts WHERE local=false AND user_id=(SELECT id FROM users WHERE ap_id=$1)"
|
||||
)
|
||||
.bind(actor_ap_url.as_str())
|
||||
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
|
||||
}
|
||||
|
||||
async fn count_local_notes(&self) -> Result<u64, DomainError> {
|
||||
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts WHERE local=true")
|
||||
.fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
Ok(n as u64)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Add `pub mod activitypub;`** to `crates/adapters/postgres/src/lib.rs` — append alongside the other module declarations.
|
||||
|
||||
- [ ] **Run:** `DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test -p postgres activitypub`
|
||||
Expected: 3 tests pass.
|
||||
|
||||
- [ ] **Commit:**
|
||||
```bash
|
||||
git add crates/adapters/postgres/src/activitypub.rs crates/adapters/postgres/src/lib.rs
|
||||
git commit -m "feat(postgres): PgActivityPubRepository implementing ActivityPubRepository port"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: activitypub adapter — use the port, drop postgres dep
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/adapters/activitypub/src/handler.rs`
|
||||
- Modify: `crates/adapters/activitypub/Cargo.toml`
|
||||
|
||||
- [ ] **Rewrite `crates/adapters/activitypub/src/handler.rs`:**
|
||||
|
||||
```rust
|
||||
use std::sync::Arc;
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use url::Url;
|
||||
|
||||
use activitypub_base::ApObjectHandler;
|
||||
use domain::ports::ActivityPubRepository;
|
||||
use domain::value_objects::UserId;
|
||||
use crate::note::ThoughtNote;
|
||||
use crate::urls::ThoughtsUrls;
|
||||
|
||||
pub struct ThoughtsObjectHandler {
|
||||
repo: Arc<dyn ActivityPubRepository>,
|
||||
urls: ThoughtsUrls,
|
||||
}
|
||||
|
||||
impl ThoughtsObjectHandler {
|
||||
pub fn new(repo: Arc<dyn ActivityPubRepository>, base_url: &str) -> Self {
|
||||
Self { repo, urls: ThoughtsUrls::new(base_url) }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ApObjectHandler for ThoughtsObjectHandler {
|
||||
async fn get_local_objects_for_user(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Result<Vec<(Url, serde_json::Value)>> {
|
||||
let uid = UserId::from_uuid(user_id);
|
||||
let entries = self.repo.outbox_entries_for_actor(&uid).await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
entries.into_iter().map(|e| {
|
||||
let note_url = self.urls.thought_url(e.thought.id.as_uuid());
|
||||
let actor_url = self.urls.user_url(e.author_username.as_str());
|
||||
let followers = self.urls.user_followers(e.author_username.as_str());
|
||||
let in_reply_to = e.thought.in_reply_to_id.map(|id| self.urls.thought_url(id.as_uuid()));
|
||||
let note = ThoughtNote::new_public(
|
||||
note_url.clone(), actor_url,
|
||||
e.thought.content.as_str().to_owned(),
|
||||
e.thought.created_at, in_reply_to,
|
||||
e.thought.sensitive, e.thought.content_warning, followers,
|
||||
);
|
||||
Ok((note_url, serde_json::to_value(¬e)?))
|
||||
}).collect()
|
||||
}
|
||||
|
||||
async fn get_local_objects_page(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
before: Option<DateTime<Utc>>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>> {
|
||||
let uid = UserId::from_uuid(user_id);
|
||||
let entries = self.repo.outbox_page_for_actor(&uid, before, limit).await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
entries.into_iter().map(|e| {
|
||||
let created_at = e.thought.created_at;
|
||||
let note_url = self.urls.thought_url(e.thought.id.as_uuid());
|
||||
let actor_url = self.urls.user_url(e.author_username.as_str());
|
||||
let followers = self.urls.user_followers(e.author_username.as_str());
|
||||
let in_reply_to = e.thought.in_reply_to_id.map(|id| self.urls.thought_url(id.as_uuid()));
|
||||
let note = ThoughtNote::new_public(
|
||||
note_url.clone(), actor_url,
|
||||
e.thought.content.as_str().to_owned(),
|
||||
created_at, in_reply_to,
|
||||
e.thought.sensitive, e.thought.content_warning, followers,
|
||||
);
|
||||
Ok((note_url, serde_json::to_value(¬e)?, created_at))
|
||||
}).collect()
|
||||
}
|
||||
|
||||
async fn on_create(
|
||||
&self,
|
||||
ap_id: &Url,
|
||||
actor_url: &Url,
|
||||
object: serde_json::Value,
|
||||
) -> Result<()> {
|
||||
let note: ThoughtNote = serde_json::from_value(object)?;
|
||||
let author_id = self.repo.intern_remote_actor(actor_url).await
|
||||
.map_err(|e| anyhow!("{e}"))?;
|
||||
self.repo.accept_note(
|
||||
ap_id, &author_id,
|
||||
¬e.content,
|
||||
note.published,
|
||||
note.sensitive,
|
||||
note.summary,
|
||||
).await.map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
|
||||
async fn on_update(
|
||||
&self,
|
||||
ap_id: &Url,
|
||||
_actor_url: &Url,
|
||||
object: serde_json::Value,
|
||||
) -> Result<()> {
|
||||
let note: ThoughtNote = serde_json::from_value(object)?;
|
||||
self.repo.apply_note_update(ap_id, ¬e.content).await
|
||||
.map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
|
||||
async fn on_delete(&self, ap_id: &Url, _actor_url: &Url) -> Result<()> {
|
||||
self.repo.retract_note(ap_id).await.map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
|
||||
async fn on_actor_removed(&self, actor_url: &Url) -> Result<()> {
|
||||
self.repo.retract_actor_notes(actor_url).await.map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> Result<u64> {
|
||||
self.repo.count_local_notes().await.map_err(|e| anyhow!("{e}"))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Rewrite `crates/adapters/activitypub/Cargo.toml`** — remove `postgres` and `sqlx`:
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "activitypub"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
activitypub-base = { workspace = true }
|
||||
activitypub_federation = "0.7.0-beta.11"
|
||||
domain = { workspace = true }
|
||||
url = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo check -p activitypub`
|
||||
Expected: no errors. If there are unused import warnings for `sqlx` or `PgPool` — those are now gone, so the check should be clean.
|
||||
|
||||
- [ ] **Run full test suite:** `DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace`
|
||||
Expected: all 67 tests pass (handler.rs has no unit tests of its own, but the workspace test suite must stay green).
|
||||
|
||||
- [ ] **Commit:**
|
||||
```bash
|
||||
git add crates/adapters/activitypub/
|
||||
git commit -m "refactor(activitypub): ThoughtsObjectHandler uses ActivityPubRepository port, drops postgres dep"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Presentation — wire PgActivityPubRepository
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/presentation/src/lib.rs`
|
||||
|
||||
The current `build_state` in `src/lib.rs` calls `ThoughtsObjectHandler::new(pool.clone(), &base_url)`. After Task 3, the signature changed to `ThoughtsObjectHandler::new(repo: Arc<dyn ActivityPubRepository>, base_url: &str)`.
|
||||
|
||||
- [ ] **Update the import and wiring in `crates/presentation/src/lib.rs`:**
|
||||
|
||||
Find the existing import line:
|
||||
```rust
|
||||
use activitypub::ThoughtsObjectHandler;
|
||||
```
|
||||
|
||||
Add alongside it:
|
||||
```rust
|
||||
use postgres::activitypub::PgActivityPubRepository;
|
||||
```
|
||||
|
||||
Find the existing call:
|
||||
```rust
|
||||
std::sync::Arc::new(ThoughtsObjectHandler::new(pool.clone(), &base_url)),
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```rust
|
||||
std::sync::Arc::new(ThoughtsObjectHandler::new(
|
||||
std::sync::Arc::new(PgActivityPubRepository::new(pool.clone())),
|
||||
&base_url,
|
||||
)),
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo build -p presentation`
|
||||
Expected: clean build, no errors.
|
||||
|
||||
- [ ] **Run full test suite:**
|
||||
```bash
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace 2>&1 | tail -3
|
||||
```
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Verify dependency is gone:**
|
||||
```bash
|
||||
cargo tree -p activitypub | grep postgres
|
||||
```
|
||||
Expected: no output — `activitypub` no longer depends on `postgres`.
|
||||
|
||||
- [ ] **Commit:**
|
||||
```bash
|
||||
git add crates/presentation/src/lib.rs
|
||||
git commit -m "fix: wire PgActivityPubRepository into ThoughtsObjectHandler — closes activitypub→postgres violation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- ✅ `OutboxEntry` struct with `thought: Thought` + `author_username: Username`
|
||||
- ✅ `ActivityPubRepository` trait in `domain/src/ports.rs` — 9 methods with federation vocabulary
|
||||
- ✅ `TestStore impl ActivityPubRepository` — idempotent `intern_remote_actor`, empty stubs for others
|
||||
- ✅ 2 domain unit tests covering idempotency and empty outbox
|
||||
- ✅ `PgActivityPubRepository` in `postgres/src/activitypub.rs` — all 9 methods
|
||||
- ✅ 3 postgres integration tests
|
||||
- ✅ `ThoughtsObjectHandler` drops `PgPool`, receives `Arc<dyn ActivityPubRepository>`
|
||||
- ✅ `activitypub/Cargo.toml` removes `postgres` and `sqlx` deps
|
||||
- ✅ Presentation wires `PgActivityPubRepository` → `ThoughtsObjectHandler`
|
||||
- ✅ `cargo tree` verification confirms violation is resolved
|
||||
|
||||
**Placeholder scan:** None.
|
||||
|
||||
**Type consistency:**
|
||||
- `OutboxEntry` defined in `domain/src/ports.rs`, imported as `domain::ports::OutboxEntry` in postgres — consistent
|
||||
- `ThoughtsObjectHandler::new(repo: Arc<dyn ActivityPubRepository>, base_url: &str)` — matches presentation wiring
|
||||
- `PgActivityPubRepository::new(pool: PgPool)` — matches presentation wiring
|
||||
- All 9 method signatures identical between trait definition (Task 1) and impl (Task 2) and handler calls (Task 3)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,360 +0,0 @@
|
||||
# Audit Gap Fixes Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Close the three gaps found in the architectural audit: `unblock_user` publishing a `UserUnblocked` event, `register` publishing a `UserRegistered` event, and the worker creating Reply notifications when a thought is a reply.
|
||||
|
||||
**Architecture:** Two new `DomainEvent` variants (`UserUnblocked`, `UserRegistered`) ripple through the event pipeline: added to `events.rs`, serialised in `event-payload`, published in the affected use cases. The worker `NotificationHandler` gains a new arm for `ThoughtCreated` with an `in_reply_to_id`.
|
||||
|
||||
**Tech Stack:** Rust, existing domain/event-payload/application/worker crates
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
```
|
||||
Modify: crates/domain/src/events.rs ← add UserUnblocked + UserRegistered variants
|
||||
Modify: crates/adapters/event-payload/src/lib.rs ← add variants + From<&DomainEvent> + TryFrom arms
|
||||
Modify: crates/application/src/use_cases/social.rs ← unblock_user accepts events, publishes UserUnblocked
|
||||
Modify: crates/application/src/use_cases/auth.rs ← register publishes UserRegistered
|
||||
Modify: crates/presentation/src/handlers/social.rs ← delete_block passes &*s.events
|
||||
Modify: crates/worker/src/handlers.rs ← ThoughtCreated arm → Reply notification
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: New DomainEvent variants + event-payload + use case fixes
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/domain/src/events.rs`
|
||||
- Modify: `crates/adapters/event-payload/src/lib.rs`
|
||||
- Modify: `crates/application/src/use_cases/social.rs`
|
||||
- Modify: `crates/application/src/use_cases/auth.rs`
|
||||
- Modify: `crates/presentation/src/handlers/social.rs`
|
||||
|
||||
- [ ] **Write failing tests** — add to `crates/application/src/use_cases/social.rs` test module (bottom of existing `#[cfg(test)] mod tests`):
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn unblock_user_publishes_event() {
|
||||
let store = TestStore::default();
|
||||
let alice = user("alice");
|
||||
let bob = user("bob");
|
||||
// block first so we can unblock
|
||||
block_user(&store, &store, &alice.id, &bob.id).await.unwrap();
|
||||
store.events.lock().unwrap().clear(); // reset after block event
|
||||
unblock_user(&store, &store, &alice.id, &bob.id).await.unwrap();
|
||||
let events = store.events.lock().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(matches!(events[0], DomainEvent::UserUnblocked { .. }));
|
||||
}
|
||||
```
|
||||
|
||||
Add to `crates/application/src/use_cases/auth.rs` test module:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn register_publishes_user_registered_event() {
|
||||
let store = TestStore::default();
|
||||
register(&store, &FakeHasher, &FakeAuth, &store, input()).await.unwrap();
|
||||
let events = store.events.lock().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert!(matches!(events[0], DomainEvent::UserRegistered { .. }));
|
||||
}
|
||||
```
|
||||
|
||||
Note: in the auth test, `&store` is passed as the `events` argument (TestStore implements EventPublisher). The existing tests use `&NoOpEventPublisher` — leave those unchanged, they still pass. Only the new test passes `&store` to capture events.
|
||||
|
||||
- [ ] **Run:** `cargo test -p application` — Expected: FAIL (UserUnblocked + UserRegistered not defined).
|
||||
|
||||
- [ ] **Add variants to `crates/domain/src/events.rs`** — append two variants to the `DomainEvent` enum, after `UserBlocked`:
|
||||
|
||||
```rust
|
||||
UserUnblocked { blocker_id: UserId, blocked_id: UserId },
|
||||
UserRegistered { user_id: UserId },
|
||||
```
|
||||
|
||||
- [ ] **Add variants to `crates/adapters/event-payload/src/lib.rs`**:
|
||||
|
||||
**In the `EventPayload` enum** — append after `UserBlocked`:
|
||||
|
||||
```rust
|
||||
UserUnblocked {
|
||||
blocker_id: String,
|
||||
blocked_id: String,
|
||||
},
|
||||
UserRegistered {
|
||||
user_id: String,
|
||||
},
|
||||
```
|
||||
|
||||
**In `subject()`** — append after the `Self::UserBlocked` arm:
|
||||
|
||||
```rust
|
||||
Self::UserUnblocked { .. } => "users.unblocked",
|
||||
Self::UserRegistered { .. } => "users.registered",
|
||||
```
|
||||
|
||||
**In `impl From<&DomainEvent> for EventPayload`** — append after the `DomainEvent::UserBlocked` arm:
|
||||
|
||||
```rust
|
||||
DomainEvent::UserUnblocked { blocker_id, blocked_id } => Self::UserUnblocked {
|
||||
blocker_id: blocker_id.to_string(),
|
||||
blocked_id: blocked_id.to_string(),
|
||||
},
|
||||
DomainEvent::UserRegistered { user_id } => Self::UserRegistered {
|
||||
user_id: user_id.to_string(),
|
||||
},
|
||||
```
|
||||
|
||||
**In `impl TryFrom<EventPayload> for DomainEvent`** — append after the `EventPayload::UserBlocked` arm:
|
||||
|
||||
```rust
|
||||
EventPayload::UserUnblocked { blocker_id, blocked_id } => DomainEvent::UserUnblocked {
|
||||
blocker_id: UserId::from_uuid(parse_uuid(&blocker_id, "blocker_id")?),
|
||||
blocked_id: UserId::from_uuid(parse_uuid(&blocked_id, "blocked_id")?),
|
||||
},
|
||||
EventPayload::UserRegistered { user_id } => DomainEvent::UserRegistered {
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Update `unblock_user` in `crates/application/src/use_cases/social.rs`**:
|
||||
|
||||
Replace the current function (which takes only `blocks` and two UserId params):
|
||||
|
||||
```rust
|
||||
pub async fn unblock_user(blocks: &dyn BlockRepository, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError> {
|
||||
blocks.delete(blocker_id, blocked_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```rust
|
||||
pub async fn unblock_user(
|
||||
blocks: &dyn BlockRepository,
|
||||
events: &dyn EventPublisher,
|
||||
blocker_id: &UserId,
|
||||
blocked_id: &UserId,
|
||||
) -> Result<(), DomainError> {
|
||||
blocks.delete(blocker_id, blocked_id).await?;
|
||||
events.publish(&DomainEvent::UserUnblocked {
|
||||
blocker_id: blocker_id.clone(),
|
||||
blocked_id: blocked_id.clone(),
|
||||
}).await?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Update `register` in `crates/application/src/use_cases/auth.rs`**:
|
||||
|
||||
Change the parameter from `_events` to `events` (remove the underscore) and add one line after `users.save(&user).await?;`:
|
||||
|
||||
```rust
|
||||
pub async fn register(
|
||||
users: &dyn UserRepository,
|
||||
hasher: &dyn PasswordHasher,
|
||||
auth: &dyn AuthService,
|
||||
events: &dyn EventPublisher, // ← remove leading underscore
|
||||
input: RegisterInput,
|
||||
) -> Result<RegisterOutput, DomainError> {
|
||||
let username = Username::new(input.username)?;
|
||||
let email = Email::new(input.email)?;
|
||||
if users.find_by_username(&username).await?.is_some() {
|
||||
return Err(DomainError::Conflict("username taken".into()));
|
||||
}
|
||||
if users.find_by_email(&email).await?.is_some() {
|
||||
return Err(DomainError::Conflict("email taken".into()));
|
||||
}
|
||||
let hash = hasher.hash(&input.password).await?;
|
||||
let user = User::new_local(UserId::new(), username, email, hash);
|
||||
users.save(&user).await?;
|
||||
events.publish(&DomainEvent::UserRegistered { user_id: user.id.clone() }).await?; // ← new
|
||||
let token = auth.generate_token(&user.id)?;
|
||||
Ok(RegisterOutput { user, token: token.token })
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Update `delete_block` handler in `crates/presentation/src/handlers/social.rs`**:
|
||||
|
||||
The handler currently calls `unblock_user(&*s.blocks, &uid, &UserId::from_uuid(target))`. Add `&*s.events` as the second argument:
|
||||
|
||||
```rust
|
||||
pub async fn delete_block(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(target): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
||||
unblock_user(&*s.blocks, &*s.events, &uid, &UserId::from_uuid(target)).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo test -p application` — Expected: all tests pass including 2 new ones.
|
||||
|
||||
- [ ] **Run:** `cargo check --workspace` — Expected: no errors (the handler change + event-payload additions must compile).
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add crates/domain/src/events.rs \
|
||||
crates/adapters/event-payload/src/lib.rs \
|
||||
crates/application/src/use_cases/social.rs \
|
||||
crates/application/src/use_cases/auth.rs \
|
||||
crates/presentation/src/handlers/social.rs
|
||||
git commit -m "feat: UserUnblocked + UserRegistered events, fix unblock_user and register signatures"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Reply notifications in worker
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/worker/src/handlers.rs`
|
||||
|
||||
- [ ] **Write the failing test** — add to the existing `#[cfg(test)] mod tests` block in `crates/worker/src/handlers.rs`, after `follow_accepted_creates_notification`:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn reply_creates_notification_for_original_author() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice(); // author of the original thought
|
||||
let bob_id = UserId::new(); // author of the reply
|
||||
|
||||
let original = Thought::new_local(
|
||||
ThoughtId::new(), alice.id.clone(),
|
||||
Content::new_local("original thought").unwrap(),
|
||||
None, Visibility::Public, None, false,
|
||||
);
|
||||
store.users.lock().unwrap().push(alice.clone());
|
||||
store.thoughts.lock().unwrap().push(original.clone());
|
||||
|
||||
let reply_id = ThoughtId::new();
|
||||
let handler = NotificationHandler {
|
||||
thoughts: Arc::new(store.clone()),
|
||||
notifications: Arc::new(store.clone()),
|
||||
};
|
||||
|
||||
// ThoughtCreated with in_reply_to_id pointing at alice's thought
|
||||
handler.handle(&DomainEvent::ThoughtCreated {
|
||||
thought_id: reply_id,
|
||||
user_id: bob_id.clone(),
|
||||
in_reply_to_id: Some(original.id.clone()),
|
||||
}).await.unwrap();
|
||||
|
||||
let notifs = store.notifications.lock().unwrap();
|
||||
assert_eq!(notifs.len(), 1);
|
||||
assert_eq!(notifs[0].user_id, alice.id);
|
||||
assert!(matches!(notifs[0].notification_type, NotificationType::Reply));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn self_reply_does_not_create_notification() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let original = Thought::new_local(
|
||||
ThoughtId::new(), alice.id.clone(),
|
||||
Content::new_local("original").unwrap(),
|
||||
None, Visibility::Public, None, false,
|
||||
);
|
||||
store.users.lock().unwrap().push(alice.clone());
|
||||
store.thoughts.lock().unwrap().push(original.clone());
|
||||
|
||||
let handler = NotificationHandler {
|
||||
thoughts: Arc::new(store.clone()),
|
||||
notifications: Arc::new(store.clone()),
|
||||
};
|
||||
|
||||
handler.handle(&DomainEvent::ThoughtCreated {
|
||||
thought_id: ThoughtId::new(),
|
||||
user_id: alice.id.clone(), // alice replying to herself
|
||||
in_reply_to_id: Some(original.id.clone()),
|
||||
}).await.unwrap();
|
||||
|
||||
assert!(store.notifications.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thought_without_reply_to_creates_no_notification() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
store.users.lock().unwrap().push(alice.clone());
|
||||
|
||||
let handler = NotificationHandler {
|
||||
thoughts: Arc::new(store.clone()),
|
||||
notifications: Arc::new(store.clone()),
|
||||
};
|
||||
|
||||
handler.handle(&DomainEvent::ThoughtCreated {
|
||||
thought_id: ThoughtId::new(),
|
||||
user_id: alice.id.clone(),
|
||||
in_reply_to_id: None, // not a reply
|
||||
}).await.unwrap();
|
||||
|
||||
assert!(store.notifications.lock().unwrap().is_empty());
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Run:** `DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test -p worker` — Expected: FAIL on 3 new tests (reply handling not implemented).
|
||||
|
||||
- [ ] **Add the `ThoughtCreated` arm** to `NotificationHandler::handle` in `crates/worker/src/handlers.rs` — insert before the final `_ => Ok(()),` arm:
|
||||
|
||||
```rust
|
||||
DomainEvent::ThoughtCreated { thought_id, user_id, in_reply_to_id } => {
|
||||
let reply_to_id = match in_reply_to_id {
|
||||
Some(id) => id,
|
||||
None => return Ok(()), // not a reply — no notification needed
|
||||
};
|
||||
let original = match self.thoughts.find_by_id(reply_to_id).await? {
|
||||
Some(t) => t,
|
||||
None => return Ok(()), // original thought deleted — skip
|
||||
};
|
||||
if original.user_id == *user_id { return Ok(()); } // no self-notifications
|
||||
self.notifications.save(&Notification {
|
||||
id: NotificationId::new(),
|
||||
user_id: original.user_id,
|
||||
notification_type: NotificationType::Reply,
|
||||
from_user_id: Some(user_id.clone()),
|
||||
thought_id: Some(thought_id.clone()),
|
||||
read: false,
|
||||
created_at: Utc::now(),
|
||||
}).await
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo test -p worker` — Expected: all 6 tests pass (3 existing + 3 new).
|
||||
|
||||
- [ ] **Run full suite:** `DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace 2>&1 | tail -3` — Expected: all tests pass.
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add crates/worker/src/handlers.rs
|
||||
git commit -m "feat(worker): Reply notification when ThoughtCreated has in_reply_to_id"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- ✅ `UserUnblocked` added to DomainEvent (Task 1)
|
||||
- ✅ `UserRegistered` added to DomainEvent (Task 1)
|
||||
- ✅ Both variants added to EventPayload with subject routing (Task 1)
|
||||
- ✅ Both variants covered in From<&DomainEvent> and TryFrom<EventPayload> (Task 1)
|
||||
- ✅ `unblock_user` now accepts `events` and publishes `UserUnblocked` (Task 1)
|
||||
- ✅ `register` now publishes `UserRegistered` (Task 1)
|
||||
- ✅ `delete_block` handler passes `&*s.events` (Task 1)
|
||||
- ✅ `ThoughtCreated` with `in_reply_to_id` triggers Reply notification (Task 2)
|
||||
- ✅ Self-reply suppressed (Task 2)
|
||||
- ✅ Plain thought (no reply) triggers no notification (Task 2)
|
||||
|
||||
**Placeholder scan:** None.
|
||||
|
||||
**Type consistency:**
|
||||
- `DomainEvent::UserUnblocked { blocker_id: UserId, blocked_id: UserId }` — matches use case publish call and EventPayload From arm
|
||||
- `DomainEvent::UserRegistered { user_id: UserId }` — matches use case publish call and EventPayload From arm
|
||||
- `NotificationType::Reply` — already exists in `domain/src/models/notification.rs`
|
||||
- `unblock_user(blocks, events, blocker_id, blocked_id)` — matches updated handler call in `delete_block`
|
||||
|
||||
**Notes:**
|
||||
- `NotificationType::Reply` was already defined in domain models (Plan 1) — no domain model change needed
|
||||
- The `event-payload` `all_subjects_are_unique` test will catch duplicate NATS subjects — the new subjects "users.unblocked" and "users.registered" are unique
|
||||
@@ -1,431 +0,0 @@
|
||||
# Bootstrap Factory Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Extract the composition root out of `presentation` into a dedicated `bootstrap` crate with a `factory.rs` that builds all dependencies from config — so `presentation` becomes a pure HTTP library with no knowledge of concrete adapters.
|
||||
|
||||
**Architecture:** `crates/bootstrap/` is a new binary crate. It contains `config.rs` (reads env vars), `factory.rs` (creates all concrete `Arc<dyn Port>` adapters and returns `Infrastructure { state, fed_config }`), and a thin `main.rs`. `presentation` loses `[[bin]]`, `build_state`, and all concrete adapter imports — it only depends on `domain`, `application`, `api-types`, `axum`, `activitypub-base`, and UI/docs libs.
|
||||
|
||||
**Tech Stack:** existing Rust workspace, sqlx, async-nats, all existing adapter crates
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
```
|
||||
Create: crates/bootstrap/Cargo.toml ← binary crate, imports all concrete adapters
|
||||
Create: crates/bootstrap/src/config.rs ← Config struct + from_env()
|
||||
Create: crates/bootstrap/src/factory.rs ← build(config) → Infrastructure { state, fed_config }
|
||||
Create: crates/bootstrap/src/main.rs ← thin: read config, call factory, serve
|
||||
|
||||
Modify: Cargo.toml (root) ← add "crates/bootstrap" to workspace members
|
||||
Modify: crates/presentation/Cargo.toml ← remove [[bin]], remove all concrete adapter deps
|
||||
Modify: crates/presentation/src/lib.rs ← remove build_state + NoOpEventPublisher + imports
|
||||
Modify: crates/presentation/src/state.rs ← remove fed_config field
|
||||
Delete: crates/presentation/src/main.rs ← binary moves to bootstrap
|
||||
```
|
||||
|
||||
**Key design decision:** `fed_config` is removed from `AppState`. `factory::build()` returns `Infrastructure { state, fed_config }` separately. `main.rs` passes them independently to `router(&infra.fed_config).with_state(infra.state)`. This makes `AppState` pure `Arc<dyn Port>` with no infrastructure types.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create bootstrap crate
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/bootstrap/Cargo.toml`
|
||||
- Create: `crates/bootstrap/src/config.rs`
|
||||
- Create: `crates/bootstrap/src/factory.rs`
|
||||
- Create: `crates/bootstrap/src/main.rs`
|
||||
- Modify: `Cargo.toml` (root)
|
||||
|
||||
- [ ] **Add `"crates/bootstrap"` to `[workspace] members`** in root `Cargo.toml`.
|
||||
|
||||
- [ ] **Create `crates/bootstrap/Cargo.toml`:**
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "bootstrap"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "thoughts"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
presentation = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
postgres = { workspace = true }
|
||||
postgres-search = { workspace = true }
|
||||
postgres-federation = { workspace = true }
|
||||
activitypub = { workspace = true }
|
||||
activitypub-base = { workspace = true }
|
||||
nats = { workspace = true }
|
||||
auth = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
async-nats = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
tower-http = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
```
|
||||
|
||||
- [ ] **Create `crates/bootstrap/src/config.rs`:**
|
||||
|
||||
```rust
|
||||
/// All configuration read from environment variables at startup.
|
||||
pub struct Config {
|
||||
pub database_url: String,
|
||||
pub jwt_secret: String,
|
||||
pub base_url: String,
|
||||
pub nats_url: Option<String>,
|
||||
pub port: u16,
|
||||
pub allow_registration: bool,
|
||||
/// true when RUST_ENV != "production" — enables AP debug mode
|
||||
pub debug: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Self {
|
||||
dotenvy::dotenv().ok();
|
||||
Self {
|
||||
database_url: std::env::var("DATABASE_URL")
|
||||
.expect("DATABASE_URL is required"),
|
||||
jwt_secret: std::env::var("JWT_SECRET")
|
||||
.expect("JWT_SECRET is required"),
|
||||
base_url: std::env::var("BASE_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:3000".into()),
|
||||
nats_url: std::env::var("NATS_URL").ok(),
|
||||
port: std::env::var("PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(3000),
|
||||
allow_registration: std::env::var("ALLOW_REGISTRATION")
|
||||
.map(|v| v == "true")
|
||||
.unwrap_or(true),
|
||||
debug: std::env::var("RUST_ENV")
|
||||
.map(|v| v != "production")
|
||||
.unwrap_or(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Create `crates/bootstrap/src/factory.rs`:**
|
||||
|
||||
```rust
|
||||
use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use activitypub::ThoughtsObjectHandler;
|
||||
use activitypub_base::{ApFederationConfig, FederationData};
|
||||
use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher};
|
||||
use postgres::activitypub::PgActivityPubRepository;
|
||||
use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository};
|
||||
use presentation::state::AppState;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
/// Everything the binary needs to start serving: the axum state and the
|
||||
/// federation config (used when building the router).
|
||||
pub struct Infrastructure {
|
||||
pub state: AppState,
|
||||
pub fed_config: ApFederationConfig,
|
||||
}
|
||||
|
||||
// ── No-op publisher (fallback when NATS is unavailable) ──────────────────────
|
||||
|
||||
struct NoOpEventPublisher;
|
||||
|
||||
#[async_trait]
|
||||
impl EventPublisher for NoOpEventPublisher {
|
||||
async fn publish(&self, _e: &DomainEvent) -> Result<(), DomainError> { Ok(()) }
|
||||
}
|
||||
|
||||
// ── Factory ───────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn build(cfg: &Config) -> Infrastructure {
|
||||
// 1. Database connection + migrations
|
||||
let pool = PgPool::connect(&cfg.database_url)
|
||||
.await
|
||||
.expect("Failed to connect to database");
|
||||
sqlx::migrate!("../adapters/postgres/migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("Failed to run migrations");
|
||||
tracing::info!("Database connected and migrations applied");
|
||||
|
||||
// 2. Event publisher — real NATS or no-op fallback
|
||||
let event_publisher: Arc<dyn EventPublisher> = match &cfg.nats_url {
|
||||
Some(url) => match async_nats::connect(url).await {
|
||||
Ok(client) => {
|
||||
tracing::info!("Connected to NATS at {url}");
|
||||
Arc::new(nats::NatsEventPublisher::new(client))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("NATS connect failed ({e}) — falling back to no-op publisher");
|
||||
Arc::new(NoOpEventPublisher)
|
||||
}
|
||||
},
|
||||
None => {
|
||||
tracing::info!("NATS_URL not set — using no-op event publisher");
|
||||
Arc::new(NoOpEventPublisher)
|
||||
}
|
||||
};
|
||||
|
||||
// 3. ActivityPub federation
|
||||
let fed_data = FederationData::new(
|
||||
Arc::new(PostgresFederationRepository::new(pool.clone())),
|
||||
Arc::new(PostgresApUserRepository::new(pool.clone(), cfg.base_url.clone())),
|
||||
Arc::new(ThoughtsObjectHandler::new(
|
||||
Arc::new(PgActivityPubRepository::new(pool.clone())),
|
||||
&cfg.base_url,
|
||||
)),
|
||||
cfg.base_url.clone(),
|
||||
cfg.allow_registration,
|
||||
"thoughts".to_string(),
|
||||
None, // event_publisher wired separately via NATS
|
||||
);
|
||||
let fed_config = ApFederationConfig::new(fed_data, cfg.debug)
|
||||
.await
|
||||
.expect("Failed to build federation config");
|
||||
|
||||
// 4. Application state — all concrete repos injected as Arc<dyn Port>
|
||||
let state = AppState {
|
||||
users: Arc::new(postgres::user::PgUserRepository::new(pool.clone())),
|
||||
thoughts: Arc::new(postgres::thought::PgThoughtRepository::new(pool.clone())),
|
||||
likes: Arc::new(postgres::like::PgLikeRepository::new(pool.clone())),
|
||||
boosts: Arc::new(postgres::boost::PgBoostRepository::new(pool.clone())),
|
||||
follows: Arc::new(postgres::follow::PgFollowRepository::new(pool.clone())),
|
||||
blocks: Arc::new(postgres::block::PgBlockRepository::new(pool.clone())),
|
||||
tags: Arc::new(postgres::tag::PgTagRepository::new(pool.clone())),
|
||||
api_keys: Arc::new(postgres::api_key::PgApiKeyRepository::new(pool.clone())),
|
||||
top_friends: Arc::new(postgres::top_friend::PgTopFriendRepository::new(pool.clone())),
|
||||
notifications: Arc::new(postgres::notification::PgNotificationRepository::new(pool.clone())),
|
||||
remote_actors: Arc::new(postgres::remote_actor::PgRemoteActorRepository::new(pool.clone())),
|
||||
feed: Arc::new(postgres::feed::PgFeedRepository::new(pool.clone())),
|
||||
search: Arc::new(postgres_search::PgSearchRepository::new(pool.clone())),
|
||||
auth: Arc::new(auth::JwtAuthService::new(cfg.jwt_secret.clone(), 86400 * 30)),
|
||||
hasher: Arc::new(auth::Argon2PasswordHasher),
|
||||
events: event_publisher,
|
||||
};
|
||||
|
||||
Infrastructure { state, fed_config }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Create `crates/bootstrap/src/main.rs`:**
|
||||
|
||||
```rust
|
||||
mod config;
|
||||
mod factory;
|
||||
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let cfg = config::Config::from_env();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
let infra = factory::build(&cfg).await;
|
||||
|
||||
let app = presentation::routes::router(&infra.fed_config)
|
||||
.with_state(infra.state)
|
||||
.layer(CorsLayer::permissive());
|
||||
|
||||
let addr = format!("0.0.0.0:{}", cfg.port);
|
||||
tracing::info!("Listening on {addr}");
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo check -p bootstrap` — Expected: no errors.
|
||||
Note: `presentation` still has its old `[[bin]]` at this point — that's fine, both binaries exist temporarily.
|
||||
|
||||
- [ ] **Smoke test from bootstrap:**
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres \
|
||||
JWT_SECRET=dev BASE_URL=http://localhost:3000 \
|
||||
RUST_LOG=info cargo run --bin thoughts
|
||||
```
|
||||
|
||||
Open a second terminal:
|
||||
```bash
|
||||
curl -s -X POST http://localhost:3000/auth/register \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"username":"bootstraptest","email":"boot@test.com","password":"pw"}' | jq .token
|
||||
```
|
||||
Expected: returns a JWT token.
|
||||
|
||||
- [ ] **Commit:**
|
||||
```bash
|
||||
git add Cargo.toml crates/bootstrap/
|
||||
git commit -m "feat(bootstrap): composition root with Config + factory.rs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Clean presentation — strip concrete deps, remove binary
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/presentation/Cargo.toml`
|
||||
- Modify: `crates/presentation/src/lib.rs`
|
||||
- Modify: `crates/presentation/src/state.rs`
|
||||
- Delete: `crates/presentation/src/main.rs`
|
||||
|
||||
- [ ] **Remove `[[bin]]` table and `src/main.rs` from `crates/presentation/Cargo.toml`:**
|
||||
|
||||
Delete these lines entirely:
|
||||
```toml
|
||||
[[bin]]
|
||||
name = "thoughts"
|
||||
path = "src/main.rs"
|
||||
```
|
||||
|
||||
- [ ] **Strip concrete adapter deps from `crates/presentation/Cargo.toml`:**
|
||||
|
||||
Remove these lines:
|
||||
```toml
|
||||
postgres = { workspace = true }
|
||||
postgres-search = { workspace = true }
|
||||
postgres-federation = { workspace = true }
|
||||
activitypub = { workspace = true }
|
||||
nats = { workspace = true }
|
||||
async-nats = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
auth = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
```
|
||||
|
||||
Keep these (they belong to the HTTP layer):
|
||||
```toml
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
api-types = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
activitypub-base = { workspace = true }
|
||||
activitypub_federation = "0.7.0-beta.11"
|
||||
url = { workspace = true }
|
||||
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"] }
|
||||
```
|
||||
|
||||
- [ ] **Rewrite `crates/presentation/src/lib.rs`** — remove `build_state`, `NoOpEventPublisher`, and all concrete imports. The file becomes purely module declarations:
|
||||
|
||||
```rust
|
||||
pub mod errors;
|
||||
pub mod extractors;
|
||||
pub mod handlers;
|
||||
pub mod openapi;
|
||||
pub mod routes;
|
||||
pub mod state;
|
||||
```
|
||||
|
||||
- [ ] **Remove `fed_config` from `crates/presentation/src/state.rs`:**
|
||||
|
||||
The `AppState` struct currently has `pub fed_config: ApFederationConfig`. Remove that field and its import. The struct becomes:
|
||||
|
||||
```rust
|
||||
use std::sync::Arc;
|
||||
use domain::ports::*;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub users: Arc<dyn UserRepository>,
|
||||
pub thoughts: Arc<dyn ThoughtRepository>,
|
||||
pub likes: Arc<dyn LikeRepository>,
|
||||
pub boosts: Arc<dyn BoostRepository>,
|
||||
pub follows: Arc<dyn FollowRepository>,
|
||||
pub blocks: Arc<dyn BlockRepository>,
|
||||
pub tags: Arc<dyn TagRepository>,
|
||||
pub api_keys: Arc<dyn ApiKeyRepository>,
|
||||
pub top_friends: Arc<dyn TopFriendRepository>,
|
||||
pub notifications: Arc<dyn NotificationRepository>,
|
||||
pub remote_actors: Arc<dyn RemoteActorRepository>,
|
||||
pub feed: Arc<dyn FeedRepository>,
|
||||
pub search: Arc<dyn SearchPort>,
|
||||
pub auth: Arc<dyn AuthService>,
|
||||
pub hasher: Arc<dyn PasswordHasher>,
|
||||
pub events: Arc<dyn EventPublisher>,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Delete `crates/presentation/src/main.rs`:**
|
||||
|
||||
```bash
|
||||
rm crates/presentation/src/main.rs
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo check -p presentation` — Expected: no errors.
|
||||
|
||||
- [ ] **Run:** `cargo check -p bootstrap` — Expected: no errors (bootstrap now owns the binary).
|
||||
|
||||
- [ ] **Verify only bootstrap knows about postgres:**
|
||||
|
||||
```bash
|
||||
cargo tree -p presentation 2>/dev/null | grep -E "postgres|sqlx|nats|auth" | head -5 || echo "clean"
|
||||
```
|
||||
Expected: `clean` — no concrete adapter deps in presentation.
|
||||
|
||||
- [ ] **Run full test suite:**
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace 2>&1 | tail -3
|
||||
```
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add crates/presentation/Cargo.toml \
|
||||
crates/presentation/src/lib.rs \
|
||||
crates/presentation/src/state.rs
|
||||
git rm crates/presentation/src/main.rs
|
||||
git commit -m "refactor(presentation): pure HTTP library — remove build_state, concrete adapter deps, and binary"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- ✅ `bootstrap/config.rs` reads all env vars into typed `Config` struct (Task 1)
|
||||
- ✅ `bootstrap/factory.rs` builds all `Arc<dyn Port>` adapters from `Config` (Task 1)
|
||||
- ✅ `bootstrap/main.rs` is thin: read config → factory → serve (Task 1)
|
||||
- ✅ `presentation` loses `[[bin]]`, `main.rs`, `build_state`, `NoOpEventPublisher` (Task 2)
|
||||
- ✅ `presentation/Cargo.toml` no longer imports postgres, nats, auth, sqlx, etc. (Task 2)
|
||||
- ✅ `AppState` has no `fed_config` field — pure `Arc<dyn Port>` (Task 2)
|
||||
- ✅ `cargo tree -p presentation | grep postgres` returns nothing (Task 2)
|
||||
|
||||
**Placeholder scan:** None.
|
||||
|
||||
**Type consistency:**
|
||||
- `factory::build(cfg: &Config) -> Infrastructure` — matches `main.rs` call
|
||||
- `Infrastructure { state: AppState, fed_config: ApFederationConfig }` — `state` matches `routes::router().with_state(state)`, `fed_config` matches `routes::router(&infra.fed_config)`
|
||||
- `AppState` without `fed_config` — `factory.rs` constructs it correctly (no `fed_config:` field)
|
||||
- `sqlx::migrate!("../adapters/postgres/migrations")` in `factory.rs` — path is relative to `CARGO_MANIFEST_DIR` of `bootstrap` crate (`crates/bootstrap/`), resolves to `crates/adapters/postgres/migrations` ✓
|
||||
|
||||
**Note on Dockerfile:** The existing `Dockerfile` references the `thoughts` binary. Since `bootstrap/Cargo.toml` uses `[[bin]] name = "thoughts"`, the binary name is unchanged — Dockerfile needs no update.
|
||||
|
||||
**Note on worker:** `crates/worker/` is already a clean composition root — it wires its own deps in `main.rs`. No changes needed there.
|
||||
@@ -1,408 +0,0 @@
|
||||
# event-publisher Transport Abstraction Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fill `event-publisher` with a `Transport` trait + `EventPublisherAdapter<T: Transport>`, strip `NatsEventPublisher` from the `nats` crate and replace it with `NatsTransport` implementing `Transport`, then wire `EventPublisherAdapter::new(NatsTransport::new(client))` in bootstrap — so adding Kafka/Redis later only requires a new transport crate.
|
||||
|
||||
**Architecture:** `event-publisher` defines the abstraction (`Transport` + `EventPublisherAdapter`). `nats` implements `Transport` for NATS (pure bytes: publish/subscribe). `event-publisher` never imports `nats`. `bootstrap` wires them together. `NatsEventConsumer` stays in `nats` — it's transport-specific and will never be shared.
|
||||
|
||||
**Dependency chain after refactor:**
|
||||
```
|
||||
event-publisher → domain, event-payload, serde_json
|
||||
nats → domain, event-payload, event-publisher, async-nats
|
||||
bootstrap → event-publisher, nats (+ all others)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
```
|
||||
Modify: crates/adapters/event-publisher/Cargo.toml ← add deps
|
||||
Modify: crates/adapters/event-publisher/src/lib.rs ← Transport trait + EventPublisherAdapter<T>
|
||||
Modify: crates/adapters/nats/Cargo.toml ← add event-publisher dep
|
||||
Modify: crates/adapters/nats/src/lib.rs ← remove NatsEventPublisher, add NatsTransport
|
||||
Modify: crates/bootstrap/src/factory.rs ← use EventPublisherAdapter<NatsTransport>
|
||||
Modify: crates/bootstrap/Cargo.toml ← add event-publisher dep (if missing)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Fill event-publisher — Transport trait + EventPublisherAdapter
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/adapters/event-publisher/Cargo.toml`
|
||||
- Modify: `crates/adapters/event-publisher/src/lib.rs`
|
||||
|
||||
- [ ] **Write tests** at the bottom of `crates/adapters/event-publisher/src/lib.rs`:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use domain::value_objects::{ThoughtId, UserId};
|
||||
|
||||
struct SpyTransport {
|
||||
calls: Arc<Mutex<Vec<(String, Vec<u8>)>>>,
|
||||
}
|
||||
impl SpyTransport {
|
||||
fn new() -> (Self, Arc<Mutex<Vec<(String, Vec<u8>)>>>) {
|
||||
let calls = Arc::new(Mutex::new(vec![]));
|
||||
(Self { calls: calls.clone() }, calls)
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl Transport for SpyTransport {
|
||||
async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), domain::errors::DomainError> {
|
||||
self.calls.lock().unwrap().push((subject.to_string(), bytes.to_vec()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thought_created_routes_to_correct_subject() {
|
||||
let (spy, calls) = SpyTransport::new();
|
||||
let publisher = EventPublisherAdapter::new(spy);
|
||||
publisher.publish(&domain::events::DomainEvent::ThoughtCreated {
|
||||
thought_id: ThoughtId::new(),
|
||||
user_id: UserId::new(),
|
||||
in_reply_to_id: None,
|
||||
}).await.unwrap();
|
||||
let calls = calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].0, "thoughts.created");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn serialized_payload_is_valid_json() {
|
||||
let (spy, calls) = SpyTransport::new();
|
||||
let publisher = EventPublisherAdapter::new(spy);
|
||||
publisher.publish(&domain::events::DomainEvent::UserBlocked {
|
||||
blocker_id: UserId::new(),
|
||||
blocked_id: UserId::new(),
|
||||
}).await.unwrap();
|
||||
let bytes = &calls.lock().unwrap()[0].1.clone();
|
||||
let json: serde_json::Value = serde_json::from_slice(bytes).expect("valid JSON");
|
||||
assert_eq!(json["type"], "UserBlocked");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo test -p event-publisher` — Expected: FAIL (no implementation yet).
|
||||
|
||||
- [ ] **Write `crates/adapters/event-publisher/Cargo.toml`:**
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "event-publisher"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
event-payload = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
```
|
||||
|
||||
- [ ] **Write `crates/adapters/event-publisher/src/lib.rs`:**
|
||||
|
||||
```rust
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher};
|
||||
use event_payload::EventPayload;
|
||||
|
||||
/// Abstraction over any pub/sub transport backend.
|
||||
/// Implement this for NATS, Kafka, Redis Streams, etc.
|
||||
/// The adapter calls `publish_bytes(subject, bytes)` — subjects come from `EventPayload::subject()`.
|
||||
#[async_trait]
|
||||
pub trait Transport: Send + Sync {
|
||||
async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
/// Routes domain events to a transport backend.
|
||||
///
|
||||
/// Converts: `DomainEvent` → `EventPayload` (via `From`) → JSON bytes → `transport.publish_bytes(subject, bytes)`
|
||||
///
|
||||
/// To swap transports (e.g. NATS → Kafka), replace the `T` at the composition root.
|
||||
/// This type never needs to change.
|
||||
pub struct EventPublisherAdapter<T: Transport> {
|
||||
transport: T,
|
||||
}
|
||||
|
||||
impl<T: Transport> EventPublisherAdapter<T> {
|
||||
pub fn new(transport: T) -> Self {
|
||||
Self { transport }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T: Transport> EventPublisher for EventPublisherAdapter<T> {
|
||||
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
let payload = EventPayload::from(event);
|
||||
let subject = payload.subject();
|
||||
let bytes = serde_json::to_vec(&payload)
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
tracing::debug!(subject, "publishing event");
|
||||
self.transport.publish_bytes(subject, &bytes).await
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo test -p event-publisher` — Expected: 2 tests pass.
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add crates/adapters/event-publisher/
|
||||
git commit -m "feat(event-publisher): Transport trait + EventPublisherAdapter for transport-agnostic event routing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Refactor nats — strip NatsEventPublisher, add NatsTransport
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/adapters/nats/Cargo.toml`
|
||||
- Modify: `crates/adapters/nats/src/lib.rs`
|
||||
|
||||
- [ ] **Add `event-publisher` to `crates/adapters/nats/Cargo.toml`:**
|
||||
|
||||
```toml
|
||||
event-publisher = { workspace = true }
|
||||
```
|
||||
|
||||
- [ ] **Rewrite `crates/adapters/nats/src/lib.rs`** — remove `NatsEventPublisher`, add `NatsTransport`:
|
||||
|
||||
```rust
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::{DomainEvent, EventEnvelope},
|
||||
ports::EventConsumer,
|
||||
};
|
||||
use event_payload::EventPayload;
|
||||
use event_publisher::Transport;
|
||||
use futures::stream::BoxStream;
|
||||
|
||||
// ── NatsTransport — raw NATS publish backend ────────────────────────────────
|
||||
|
||||
pub struct NatsTransport {
|
||||
client: async_nats::Client,
|
||||
}
|
||||
|
||||
impl NatsTransport {
|
||||
pub fn new(client: async_nats::Client) -> Self { Self { client } }
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Transport for NatsTransport {
|
||||
async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError> {
|
||||
self.client
|
||||
.publish(subject, bytes.to_vec().into())
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
// ── NatsEventConsumer — subscribes and yields EventEnvelopes ────────────────
|
||||
|
||||
pub struct NatsEventConsumer {
|
||||
client: async_nats::Client,
|
||||
}
|
||||
|
||||
impl NatsEventConsumer {
|
||||
pub fn new(client: async_nats::Client) -> Self { Self { client } }
|
||||
}
|
||||
|
||||
impl EventConsumer for NatsEventConsumer {
|
||||
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
|
||||
let client = self.client.clone();
|
||||
Box::pin(async_stream::try_stream! {
|
||||
let mut sub = client
|
||||
.subscribe(">")
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
use futures::StreamExt;
|
||||
while let Some(msg) = sub.next().await {
|
||||
let payload = match serde_json::from_slice::<EventPayload>(&msg.payload) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to deserialize event payload: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let event = match DomainEvent::try_from(payload) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to convert payload to domain event: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
yield EventEnvelope {
|
||||
event,
|
||||
ack: Box::new(|| {}),
|
||||
nack: Box::new(|| {}),
|
||||
};
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::value_objects::{LikeId, ThoughtId, UserId};
|
||||
|
||||
#[test]
|
||||
fn payload_from_domain_event_has_correct_subject() {
|
||||
let event = DomainEvent::ThoughtCreated {
|
||||
thought_id: ThoughtId::new(),
|
||||
user_id: UserId::new(),
|
||||
in_reply_to_id: None,
|
||||
};
|
||||
let payload = EventPayload::from(&event);
|
||||
assert_eq!(payload.subject(), "thoughts.created");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_event_roundtrip_via_payload() {
|
||||
let uid = UserId::new();
|
||||
let tid = ThoughtId::new();
|
||||
let event = DomainEvent::LikeAdded {
|
||||
like_id: LikeId::new(),
|
||||
user_id: uid.clone(),
|
||||
thought_id: tid.clone(),
|
||||
};
|
||||
let payload = EventPayload::from(&event);
|
||||
let back = DomainEvent::try_from(payload).unwrap();
|
||||
if let DomainEvent::LikeAdded { user_id, thought_id, .. } = back {
|
||||
assert_eq!(user_id, uid);
|
||||
assert_eq!(thought_id, tid);
|
||||
} else {
|
||||
panic!("wrong variant");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo test -p nats` — Expected: 2 tests pass.
|
||||
|
||||
- [ ] **Run:** `cargo check --workspace` — Expected: one error in `bootstrap` (uses removed `NatsEventPublisher`) — this is expected and fixed in Task 3.
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add crates/adapters/nats/
|
||||
git commit -m "refactor(nats): strip NatsEventPublisher, add NatsTransport implementing Transport"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Wire EventPublisherAdapter in bootstrap
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/bootstrap/Cargo.toml`
|
||||
- Modify: `crates/bootstrap/src/factory.rs`
|
||||
|
||||
- [ ] **Add `event-publisher` to `crates/bootstrap/Cargo.toml`:**
|
||||
|
||||
```toml
|
||||
event-publisher = { workspace = true }
|
||||
```
|
||||
|
||||
- [ ] **Update `crates/bootstrap/src/factory.rs`** — find the NATS event publisher section and replace:
|
||||
|
||||
Find (in the `build` function):
|
||||
```rust
|
||||
Arc::new(nats::NatsEventPublisher::new(client))
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```rust
|
||||
Arc::new(event_publisher::EventPublisherAdapter::new(nats::NatsTransport::new(client)))
|
||||
```
|
||||
|
||||
The `use` imports at the top of `factory.rs` need `event_publisher` in scope. Add:
|
||||
```rust
|
||||
use event_publisher::EventPublisherAdapter;
|
||||
```
|
||||
|
||||
The `NoOpEventPublisher` struct and its `impl EventPublisher` stays in `factory.rs` — it's the fallback when NATS is unavailable and lives correctly in the composition root.
|
||||
|
||||
- [ ] **Run:** `cargo check -p bootstrap` — Expected: no errors.
|
||||
|
||||
- [ ] **Run:** `cargo check --workspace` — Expected: no errors.
|
||||
|
||||
- [ ] **Run full test suite:**
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace 2>&1 | tail -3
|
||||
```
|
||||
|
||||
Expected: all tests pass (including new event-publisher tests).
|
||||
|
||||
- [ ] **Smoke test:**
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres \
|
||||
JWT_SECRET=dev BASE_URL=http://localhost:3000 \
|
||||
RUST_LOG=info cargo run -p bootstrap &
|
||||
sleep 3
|
||||
curl -s http://localhost:3000/health | jq .
|
||||
kill %1 2>/dev/null
|
||||
```
|
||||
|
||||
Expected: `{"status":"ok","db":"connected"}`.
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add crates/bootstrap/
|
||||
git commit -m "feat(bootstrap): wire EventPublisherAdapter<NatsTransport> — transport-agnostic event publishing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- ✅ `Transport` trait in `event-publisher` with `publish_bytes(subject, bytes)` (Task 1)
|
||||
- ✅ `EventPublisherAdapter<T: Transport>` implements `EventPublisher` (Task 1)
|
||||
- ✅ 2 tests: correct subject routing, valid JSON serialization (Task 1)
|
||||
- ✅ `NatsEventPublisher` removed from `nats` (Task 2)
|
||||
- ✅ `NatsTransport` implements `Transport` for NATS (Task 2)
|
||||
- ✅ `NatsEventConsumer` unchanged — stays in `nats` (Task 2)
|
||||
- ✅ `bootstrap` wires `EventPublisherAdapter::new(NatsTransport::new(client))` (Task 3)
|
||||
- ✅ `NoOpEventPublisher` stays in `factory.rs` as fallback (Task 3)
|
||||
|
||||
**Placeholder scan:** None.
|
||||
|
||||
**Type consistency:**
|
||||
- `EventPublisherAdapter<NatsTransport>` — `NatsTransport` implements `Transport`, `EventPublisherAdapter<T: Transport>` implements `EventPublisher` ✓
|
||||
- `event_publisher::Transport` imported in `nats/src/lib.rs` — `nats` depends on `event-publisher` ✓
|
||||
- `factory.rs` uses `event_publisher::EventPublisherAdapter` and `nats::NatsTransport` — both in bootstrap deps ✓
|
||||
|
||||
**Adding Kafka later:**
|
||||
```toml
|
||||
# kafka/Cargo.toml
|
||||
[dependencies]
|
||||
event-publisher = { workspace = true }
|
||||
rdkafka = "..."
|
||||
```
|
||||
```rust
|
||||
// kafka/src/lib.rs
|
||||
pub struct KafkaTransport { ... }
|
||||
#[async_trait] impl Transport for KafkaTransport { ... }
|
||||
```
|
||||
```rust
|
||||
// bootstrap/src/factory.rs — only this line changes:
|
||||
Arc::new(EventPublisherAdapter::new(KafkaTransport::new(...)))
|
||||
```
|
||||
@@ -1,483 +0,0 @@
|
||||
# event-transport Rename + Consumer Abstraction Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Rename `event-publisher` → `event-transport` and add the symmetric consumer abstraction (`MessageSource` trait + `EventConsumerAdapter<S>`) so both publish and subscribe are transport-agnostic.
|
||||
|
||||
**Architecture after this plan:**
|
||||
```
|
||||
event-transport/ ← Transport + EventPublisherAdapter<T> (existing)
|
||||
← MessageSource + EventConsumerAdapter<S> (new)
|
||||
← RawMessage { subject, payload, ack, nack } (new)
|
||||
|
||||
nats/ ← NatsTransport (existing, implements Transport)
|
||||
← NatsMessageSource (new, implements MessageSource)
|
||||
← NatsEventConsumer removed
|
||||
|
||||
worker/ ← EventConsumerAdapter::new(NatsMessageSource::new(client))
|
||||
```
|
||||
|
||||
**Dependency chain:**
|
||||
```
|
||||
event-transport → domain, event-payload, serde_json, async-trait
|
||||
nats → domain, event-payload, event-transport, async-nats
|
||||
worker → domain, nats, event-transport, postgres
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
```
|
||||
Rename: crates/adapters/event-publisher/ → crates/adapters/event-transport/
|
||||
Modify: Cargo.toml (root) ← update member path + workspace dep name
|
||||
Modify: crates/adapters/event-transport/Cargo.toml ← name = "event-transport"
|
||||
Modify: crates/adapters/nats/Cargo.toml ← event-publisher → event-transport
|
||||
Modify: crates/adapters/nats/src/lib.rs ← use event_transport; add NatsMessageSource; remove NatsEventConsumer
|
||||
Modify: crates/bootstrap/Cargo.toml ← event-publisher → event-transport
|
||||
Modify: crates/bootstrap/src/factory.rs ← use event_transport; update EventConsumerAdapter wiring
|
||||
Modify: crates/worker/Cargo.toml ← add event-transport dep
|
||||
Modify: crates/worker/src/main.rs ← EventConsumerAdapter<NatsMessageSource>
|
||||
Modify: crates/adapters/event-transport/src/lib.rs ← add RawMessage + MessageSource + EventConsumerAdapter
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Rename crate + update all references
|
||||
|
||||
**Files:** root `Cargo.toml`, `event-publisher/Cargo.toml` (renamed), `nats/Cargo.toml`, `bootstrap/Cargo.toml`, `nats/src/lib.rs`, `bootstrap/src/factory.rs`
|
||||
|
||||
- [ ] **Rename the directory:**
|
||||
|
||||
```bash
|
||||
git mv crates/adapters/event-publisher crates/adapters/event-transport
|
||||
```
|
||||
|
||||
- [ ] **Update `crates/adapters/event-transport/Cargo.toml`** — change the package name:
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "event-transport"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
event-payload = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
```
|
||||
|
||||
- [ ] **Update root `Cargo.toml`:**
|
||||
|
||||
In `[workspace] members`, change:
|
||||
```toml
|
||||
"crates/adapters/event-publisher",
|
||||
```
|
||||
to:
|
||||
```toml
|
||||
"crates/adapters/event-transport",
|
||||
```
|
||||
|
||||
In `[workspace.dependencies]`, change:
|
||||
```toml
|
||||
event-publisher = { path = "crates/adapters/event-publisher" }
|
||||
```
|
||||
to:
|
||||
```toml
|
||||
event-transport = { path = "crates/adapters/event-transport" }
|
||||
```
|
||||
|
||||
- [ ] **Update `crates/adapters/nats/Cargo.toml`:**
|
||||
|
||||
Change `event-publisher = { workspace = true }` to `event-transport = { workspace = true }`.
|
||||
|
||||
- [ ] **Update `crates/adapters/nats/src/lib.rs`:**
|
||||
|
||||
Change `use event_publisher::Transport;` to `use event_transport::Transport;`.
|
||||
|
||||
- [ ] **Update `crates/bootstrap/Cargo.toml`:**
|
||||
|
||||
Change `event-publisher = { workspace = true }` to `event-transport = { workspace = true }`.
|
||||
|
||||
- [ ] **Update `crates/bootstrap/src/factory.rs`:**
|
||||
|
||||
Change `use event_publisher::EventPublisherAdapter;` to `use event_transport::EventPublisherAdapter;`.
|
||||
|
||||
- [ ] **Run:** `cargo check --workspace` — Expected: no errors.
|
||||
|
||||
- [ ] **Run:** `cargo test -p event-transport` — Expected: 2 tests pass (same tests as before, just crate renamed).
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add Cargo.toml \
|
||||
crates/adapters/event-transport/ \
|
||||
crates/adapters/nats/Cargo.toml \
|
||||
crates/adapters/nats/src/lib.rs \
|
||||
crates/bootstrap/Cargo.toml \
|
||||
crates/bootstrap/src/factory.rs
|
||||
git commit -m "refactor: rename event-publisher → event-transport"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add MessageSource + EventConsumerAdapter to event-transport
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/adapters/event-transport/src/lib.rs`
|
||||
|
||||
- [ ] **Write failing tests** — append to the test module in `src/lib.rs`:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn consumer_adapter_deserializes_and_yields_event() {
|
||||
use domain::value_objects::ThoughtId;
|
||||
use futures::StreamExt;
|
||||
|
||||
// Produce a serialized EventPayload for ThoughtCreated
|
||||
let event = DomainEvent::ThoughtCreated {
|
||||
thought_id: ThoughtId::new(),
|
||||
user_id: UserId::new(),
|
||||
in_reply_to_id: None,
|
||||
};
|
||||
let payload = EventPayload::from(&event);
|
||||
let bytes = serde_json::to_vec(&payload).unwrap();
|
||||
|
||||
// A MessageSource that yields one message then ends
|
||||
struct OneMessageSource { bytes: Vec<u8> }
|
||||
#[async_trait]
|
||||
impl MessageSource for OneMessageSource {
|
||||
fn messages(&self) -> futures::stream::BoxStream<'_, Result<RawMessage, DomainError>> {
|
||||
let msg = RawMessage {
|
||||
subject: "thoughts.created".to_string(),
|
||||
payload: self.bytes.clone(),
|
||||
ack: Box::new(|| {}),
|
||||
nack: Box::new(|| {}),
|
||||
};
|
||||
Box::pin(futures::stream::once(async { Ok(msg) }))
|
||||
}
|
||||
}
|
||||
|
||||
let adapter = EventConsumerAdapter::new(OneMessageSource { bytes });
|
||||
let mut stream = adapter.consume();
|
||||
let envelope = stream.next().await.unwrap().unwrap();
|
||||
assert!(matches!(envelope.event, DomainEvent::ThoughtCreated { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consumer_adapter_skips_invalid_payloads() {
|
||||
use futures::StreamExt;
|
||||
|
||||
struct BadMessageSource;
|
||||
#[async_trait]
|
||||
impl MessageSource for BadMessageSource {
|
||||
fn messages(&self) -> futures::stream::BoxStream<'_, Result<RawMessage, DomainError>> {
|
||||
let msg = RawMessage {
|
||||
subject: "bad".to_string(),
|
||||
payload: b"not valid json".to_vec(),
|
||||
ack: Box::new(|| {}),
|
||||
nack: Box::new(|| {}),
|
||||
};
|
||||
Box::pin(futures::stream::once(async { Ok(msg) }))
|
||||
}
|
||||
}
|
||||
|
||||
let adapter = EventConsumerAdapter::new(BadMessageSource);
|
||||
let mut stream = adapter.consume();
|
||||
// Invalid JSON should be skipped — stream ends with no items
|
||||
assert!(stream.next().await.is_none());
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo test -p event-transport` — Expected: FAIL (MessageSource, RawMessage, EventConsumerAdapter not defined).
|
||||
|
||||
- [ ] **Add to `crates/adapters/event-transport/src/lib.rs`** — append after the existing `EventPublisherAdapter` impl and before `#[cfg(test)]`:
|
||||
|
||||
```rust
|
||||
use domain::{events::EventEnvelope, ports::EventConsumer};
|
||||
use futures::stream::BoxStream;
|
||||
|
||||
/// A raw inbound message from a transport backend.
|
||||
/// `ack` and `nack` are transport-level acknowledgements (e.g. Kafka offset commit).
|
||||
/// For at-most-once transports (basic NATS), both are no-ops.
|
||||
pub struct RawMessage {
|
||||
pub subject: String,
|
||||
pub payload: Vec<u8>,
|
||||
pub ack: Box<dyn Fn() + Send + Sync>,
|
||||
pub nack: Box<dyn Fn() + Send + Sync>,
|
||||
}
|
||||
|
||||
/// Abstraction over any subscribe/consume backend.
|
||||
/// Implement this for NATS, Kafka, Redis Streams, etc.
|
||||
pub trait MessageSource: Send + Sync {
|
||||
fn messages(&self) -> BoxStream<'_, Result<RawMessage, DomainError>>;
|
||||
}
|
||||
|
||||
/// Deserializes raw transport messages into domain `EventEnvelope`s.
|
||||
///
|
||||
/// Converts: `RawMessage.payload` → `EventPayload` → `DomainEvent` → `EventEnvelope`
|
||||
///
|
||||
/// Invalid or unknown messages are skipped with a warning — the stream continues.
|
||||
pub struct EventConsumerAdapter<S: MessageSource> {
|
||||
source: S,
|
||||
}
|
||||
|
||||
impl<S: MessageSource> EventConsumerAdapter<S> {
|
||||
pub fn new(source: S) -> Self { Self { source } }
|
||||
}
|
||||
|
||||
impl<S: MessageSource> EventConsumer for EventConsumerAdapter<S> {
|
||||
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
|
||||
use futures::StreamExt;
|
||||
let stream = self.source.messages();
|
||||
Box::pin(stream.filter_map(|result| async move {
|
||||
match result {
|
||||
Err(e) => {
|
||||
tracing::warn!("transport error: {e}");
|
||||
None
|
||||
}
|
||||
Ok(msg) => {
|
||||
let payload = match serde_json::from_slice::<EventPayload>(&msg.payload) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to deserialize event payload: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let event = match DomainEvent::try_from(payload) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!("unknown event type: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
Some(Ok(EventEnvelope {
|
||||
event,
|
||||
ack: msg.ack,
|
||||
nack: msg.nack,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: the existing imports at the top of `lib.rs` already have `use domain::...` — add `EventEnvelope` and `EventConsumer` to those imports. Also add `futures::stream::BoxStream` if not already present.
|
||||
|
||||
Also add `futures = { workspace = true }` to `event-transport/Cargo.toml` dependencies (needed for `BoxStream` and `StreamExt`).
|
||||
|
||||
- [ ] **Run:** `cargo test -p event-transport` — Expected: 4 tests pass (2 existing + 2 new).
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add crates/adapters/event-transport/
|
||||
git commit -m "feat(event-transport): MessageSource trait + EventConsumerAdapter for transport-agnostic consuming"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: nats — add NatsMessageSource, remove NatsEventConsumer
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/adapters/nats/src/lib.rs`
|
||||
|
||||
- [ ] **Rewrite `crates/adapters/nats/src/lib.rs`** — remove `NatsEventConsumer`, add `NatsMessageSource`:
|
||||
|
||||
```rust
|
||||
use async_trait::async_trait;
|
||||
use domain::errors::DomainError;
|
||||
use event_payload::EventPayload;
|
||||
use event_transport::{MessageSource, RawMessage, Transport};
|
||||
use futures::stream::BoxStream;
|
||||
|
||||
// ── NatsTransport — raw NATS publish backend ────────────────────────────────
|
||||
|
||||
pub struct NatsTransport {
|
||||
client: async_nats::Client,
|
||||
}
|
||||
|
||||
impl NatsTransport {
|
||||
pub fn new(client: async_nats::Client) -> Self { Self { client } }
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Transport for NatsTransport {
|
||||
async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError> {
|
||||
self.client
|
||||
.publish(subject, bytes.to_vec().into())
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
// ── NatsMessageSource — raw NATS subscribe backend ──────────────────────────
|
||||
|
||||
pub struct NatsMessageSource {
|
||||
client: async_nats::Client,
|
||||
}
|
||||
|
||||
impl NatsMessageSource {
|
||||
pub fn new(client: async_nats::Client) -> Self { Self { client } }
|
||||
}
|
||||
|
||||
impl MessageSource for NatsMessageSource {
|
||||
fn messages(&self) -> BoxStream<'_, Result<RawMessage, DomainError>> {
|
||||
let client = self.client.clone();
|
||||
Box::pin(async_stream::try_stream! {
|
||||
let mut sub = client
|
||||
.subscribe(">")
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
use futures::StreamExt;
|
||||
while let Some(msg) = sub.next().await {
|
||||
let subject = msg.subject.to_string();
|
||||
let payload = msg.payload.to_vec();
|
||||
// Basic NATS: at-most-once delivery — ack/nack are no-ops.
|
||||
// Replace with JetStream for at-least-once delivery.
|
||||
yield RawMessage {
|
||||
subject,
|
||||
payload,
|
||||
ack: Box::new(|| {}),
|
||||
nack: Box::new(|| {}),
|
||||
};
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::{events::DomainEvent, value_objects::{LikeId, ThoughtId, UserId}};
|
||||
|
||||
#[test]
|
||||
fn payload_from_domain_event_has_correct_subject() {
|
||||
let event = DomainEvent::ThoughtCreated {
|
||||
thought_id: ThoughtId::new(),
|
||||
user_id: UserId::new(),
|
||||
in_reply_to_id: None,
|
||||
};
|
||||
let payload = EventPayload::from(&event);
|
||||
assert_eq!(payload.subject(), "thoughts.created");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_event_roundtrip_via_payload() {
|
||||
let uid = UserId::new();
|
||||
let tid = ThoughtId::new();
|
||||
let event = DomainEvent::LikeAdded {
|
||||
like_id: LikeId::new(),
|
||||
user_id: uid.clone(),
|
||||
thought_id: tid.clone(),
|
||||
};
|
||||
let payload = EventPayload::from(&event);
|
||||
let back = DomainEvent::try_from(payload).unwrap();
|
||||
if let DomainEvent::LikeAdded { user_id, thought_id, .. } = back {
|
||||
assert_eq!(user_id, uid);
|
||||
assert_eq!(thought_id, tid);
|
||||
} else {
|
||||
panic!("wrong variant");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo test -p nats` — Expected: 2 tests pass.
|
||||
|
||||
- [ ] **Run:** `cargo check --workspace` — Expected: one error in `worker` (uses removed `NatsEventConsumer`). That's expected — fixed in Task 4.
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add crates/adapters/nats/src/lib.rs
|
||||
git commit -m "refactor(nats): replace NatsEventConsumer with NatsMessageSource implementing MessageSource"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Update worker + full verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/worker/Cargo.toml`
|
||||
- Modify: `crates/worker/src/main.rs`
|
||||
|
||||
- [ ] **Add `event-transport = { workspace = true }` to `crates/worker/Cargo.toml`.**
|
||||
|
||||
- [ ] **Update `crates/worker/src/main.rs`** — find and update the consumer creation.
|
||||
|
||||
Current code in `main.rs`:
|
||||
```rust
|
||||
let consumer = nats::NatsEventConsumer::new(nats_client);
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```rust
|
||||
use event_transport::EventConsumerAdapter;
|
||||
use nats::NatsMessageSource;
|
||||
let consumer = EventConsumerAdapter::new(NatsMessageSource::new(nats_client));
|
||||
```
|
||||
|
||||
Also add the `use` statements at the top of `main.rs` alongside existing imports.
|
||||
|
||||
- [ ] **Run:** `cargo check --workspace` — Expected: no errors.
|
||||
|
||||
- [ ] **Run full test suite:**
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace 2>&1 | tail -3
|
||||
```
|
||||
|
||||
Expected: all tests pass (79 existing + 2 new event-transport consumer tests = 81+).
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add crates/worker/
|
||||
git commit -m "feat(worker): use EventConsumerAdapter<NatsMessageSource> — transport-agnostic consuming"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- ✅ `event-publisher` renamed to `event-transport` everywhere (Task 1)
|
||||
- ✅ `RawMessage { subject, payload, ack, nack }` in `event-transport` (Task 2)
|
||||
- ✅ `MessageSource` trait with `messages() -> BoxStream<RawMessage>` (Task 2)
|
||||
- ✅ `EventConsumerAdapter<S: MessageSource>` implementing `EventConsumer` (Task 2)
|
||||
- ✅ Invalid messages skipped with warning, stream continues (Task 2)
|
||||
- ✅ 2 new tests: valid deserialization + invalid JSON skip (Task 2)
|
||||
- ✅ `NatsEventConsumer` removed from nats (Task 3)
|
||||
- ✅ `NatsMessageSource` implementing `MessageSource` added to nats (Task 3)
|
||||
- ✅ Worker uses `EventConsumerAdapter::new(NatsMessageSource::new(client))` (Task 4)
|
||||
|
||||
**Adding Kafka later:**
|
||||
```toml
|
||||
# kafka/Cargo.toml: event-transport = { workspace = true }
|
||||
```
|
||||
```rust
|
||||
// kafka/src/lib.rs
|
||||
pub struct KafkaMessageSource { ... }
|
||||
impl MessageSource for KafkaMessageSource { ... } // yields RawMessage + real ack/nack
|
||||
|
||||
pub struct KafkaTransport { ... }
|
||||
impl Transport for KafkaTransport { ... }
|
||||
```
|
||||
```rust
|
||||
// bootstrap/src/factory.rs — two lines change:
|
||||
EventPublisherAdapter::new(KafkaTransport::new(...))
|
||||
EventConsumerAdapter::new(KafkaMessageSource::new(...))
|
||||
```
|
||||
|
||||
**Type consistency:**
|
||||
- `EventConsumerAdapter<NatsMessageSource>` — `NatsMessageSource` implements `MessageSource`, adapter implements `EventConsumer` ✓
|
||||
- `RawMessage.ack` / `.nack` transferred to `EventEnvelope.ack` / `.nack` in consumer adapter ✓
|
||||
- `event_transport::` (underscore) is the Rust module name for `event-transport` (dash) crate ✓
|
||||
@@ -1,350 +0,0 @@
|
||||
# Federation Follow-ups Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Two targeted follow-ups from the federation handler implementation: (1) handle `BoostRemoved` → `Undo(Announce)` fan-out, which was a known missing feature; (2) extract the repeated follower-filtering block in `ActivityPubService` into a private helper to eliminate duplication across 6 broadcast methods.
|
||||
|
||||
**Architecture:** Both changes are additive and self-contained. Task 1 touches `domain/ports.rs`, `activitypub-base/src/service.rs`, and `application/src/services/federation_event.rs`. Task 2 touches only `activitypub-base/src/service.rs`.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
```
|
||||
Task 1:
|
||||
Modify: crates/domain/src/ports.rs ← add broadcast_undo_announce to OutboundFederationPort
|
||||
Modify: crates/adapters/activitypub-base/src/service.rs ← broadcast_undo_announce_to_followers + impl
|
||||
Modify: crates/application/src/services/federation_event.rs ← handle BoostRemoved + tests
|
||||
|
||||
Task 2:
|
||||
Modify: crates/adapters/activitypub-base/src/service.rs ← extract accepted_follower_inboxes helper
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: BoostRemoved → Undo(Announce)
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/domain/src/ports.rs`
|
||||
- Modify: `crates/adapters/activitypub-base/src/service.rs`
|
||||
- Modify: `crates/application/src/services/federation_event.rs`
|
||||
|
||||
#### Step A: Add `broadcast_undo_announce` to `OutboundFederationPort`
|
||||
|
||||
- [ ] In `crates/domain/src/ports.rs`, add one method to `OutboundFederationPort` after `broadcast_announce`:
|
||||
|
||||
```rust
|
||||
/// Fan out an Undo(Announce) to followers when a boost is removed.
|
||||
async fn broadcast_undo_announce(
|
||||
&self,
|
||||
booster_user_id: &UserId,
|
||||
object_ap_id: &str,
|
||||
) -> Result<(), DomainError>;
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo check -p domain` — Expected: error in activitypub-base (trait impl missing method). This is expected.
|
||||
|
||||
#### Step B: Add `broadcast_undo_announce_to_followers` to `ActivityPubService` and implement the port method
|
||||
|
||||
- [ ] In `crates/adapters/activitypub-base/src/service.rs`, add `broadcast_undo_announce_to_followers` to `impl ActivityPubService` — insert after `broadcast_announce_to_followers`:
|
||||
|
||||
```rust
|
||||
/// Fan out an Undo(Announce) activity to all accepted followers.
|
||||
pub async fn broadcast_undo_announce_to_followers(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
object_ap_id: url::Url,
|
||||
) -> anyhow::Result<()> {
|
||||
let data = self.federation_config.to_request_data();
|
||||
let local_actor = get_local_actor(local_user_id, &data)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
let followers = data.federation_repo.get_followers(local_user_id).await?;
|
||||
let blocked = data.federation_repo.get_blocked_actors(local_user_id).await.unwrap_or_default();
|
||||
let blocked_set: std::collections::HashSet<String> = blocked.into_iter().collect();
|
||||
let blocked_domains = data.federation_repo.get_blocked_domains().await.unwrap_or_default();
|
||||
let blocked_domain_set: std::collections::HashSet<String> =
|
||||
blocked_domains.into_iter().map(|d| d.domain).collect();
|
||||
|
||||
let accepted: Vec<_> = followers
|
||||
.into_iter()
|
||||
.filter(|f| f.status == FollowerStatus::Accepted)
|
||||
.filter(|f| !blocked_set.contains(&f.actor.url))
|
||||
.filter(|f| {
|
||||
let domain = url::Url::parse(&f.actor.inbox_url)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default();
|
||||
!blocked_domain_set.contains(&domain)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if accepted.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let undo_id = crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let undo = crate::activities::UndoActivity {
|
||||
id: undo_id,
|
||||
kind: Default::default(),
|
||||
actor: activitypub_federation::fetch::object_id::ObjectId::from(local_actor.ap_id.clone()),
|
||||
object: serde_json::json!({
|
||||
"type": "Announce",
|
||||
"actor": local_actor.ap_id.to_string(),
|
||||
"object": object_ap_id.to_string(),
|
||||
}),
|
||||
};
|
||||
|
||||
let inboxes = collect_inboxes(&accepted);
|
||||
let sends = activitypub_federation::activity_sending::SendActivityTask::prepare(
|
||||
&activitypub_federation::protocol::context::WithContext::new_default(undo),
|
||||
&local_actor,
|
||||
inboxes,
|
||||
&data,
|
||||
)
|
||||
.await?;
|
||||
let failures = send_with_retry(sends, &data).await;
|
||||
if !failures.is_empty() {
|
||||
tracing::warn!(count = failures.len(), "some Undo(Announce) deliveries failed");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Add `broadcast_undo_announce` to the `impl domain::ports::OutboundFederationPort for ActivityPubService` block:
|
||||
|
||||
```rust
|
||||
async fn broadcast_undo_announce(
|
||||
&self,
|
||||
booster_user_id: &domain::value_objects::UserId,
|
||||
object_ap_id: &str,
|
||||
) -> Result<(), domain::errors::DomainError> {
|
||||
let user_uuid = booster_user_id.as_uuid();
|
||||
let ap_id = url::Url::parse(object_ap_id)
|
||||
.map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?;
|
||||
self.broadcast_undo_announce_to_followers(user_uuid, ap_id)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::Internal(e.to_string()))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo check -p activitypub-base` — Expected: no errors.
|
||||
- [ ] **Run:** `cargo check --workspace` — Expected: no errors.
|
||||
|
||||
#### Step C: Handle `BoostRemoved` in `FederationEventService`
|
||||
|
||||
- [ ] **Write failing test** first — add to the `#[cfg(test)] mod tests` block in `crates/application/src/services/federation_event.rs`:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn boost_removed_sends_undo_announce_for_local_thought() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let thought = local_thought(alice.id.clone()); // ap_id = None → constructed URL
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::BoostRemoved {
|
||||
user_id: alice.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let announced = spy.announced.lock().unwrap();
|
||||
assert_eq!(announced.len(), 1);
|
||||
assert_eq!(announced[0], format!("https://example.com/thoughts/{}", thought.id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn boost_removed_sends_undo_announce_for_remote_thought() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let mut thought = local_thought(alice.id.clone());
|
||||
thought.local = false;
|
||||
thought.ap_id = Some("https://mastodon.social/users/bob/statuses/456".into());
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
.process(&DomainEvent::BoostRemoved {
|
||||
user_id: alice.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let announced = spy.announced.lock().unwrap();
|
||||
assert_eq!(announced[0], "https://mastodon.social/users/bob/statuses/456");
|
||||
}
|
||||
```
|
||||
|
||||
NOTE: The `SpyPort` tracks `broadcast_undo_announce` calls in the same `announced` vec as `broadcast_announce` (or a new `undo_announced` vec — your choice, but be consistent in both the spy and the assertions).
|
||||
|
||||
Actually, use a separate `undo_announced` vec for clarity:
|
||||
|
||||
```rust
|
||||
#[derive(Default)]
|
||||
struct SpyPort {
|
||||
created: Mutex<Vec<ThoughtId>>,
|
||||
deleted: Mutex<Vec<String>>,
|
||||
updated: Mutex<Vec<ThoughtId>>,
|
||||
announced: Mutex<Vec<String>>,
|
||||
undo_announced: Mutex<Vec<String>>,
|
||||
}
|
||||
```
|
||||
|
||||
And add the impl method:
|
||||
```rust
|
||||
async fn broadcast_undo_announce(&self, _: &UserId, ap_id: &str) -> Result<(), DomainError> {
|
||||
self.undo_announced.lock().unwrap().push(ap_id.to_string());
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
Update the test assertions to use `spy.undo_announced`.
|
||||
|
||||
- [ ] **Run:** `cargo test -p application -- services::federation_event` — Expected: 2 new tests FAIL (not implemented).
|
||||
|
||||
- [ ] **Add `BoostRemoved` arm** to `FederationEventService::process` — insert after the `BoostAdded` arm:
|
||||
|
||||
```rust
|
||||
DomainEvent::BoostRemoved { user_id, thought_id } => {
|
||||
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||
Some(t) => t,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let object_ap_id = thought.ap_id.clone().unwrap_or_else(|| {
|
||||
format!("{}/thoughts/{}", self.base_url, thought_id)
|
||||
});
|
||||
self.ap.broadcast_undo_announce(user_id, &object_ap_id).await
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo test -p application -- services::federation_event` — Expected: all tests pass (now 13).
|
||||
|
||||
- [ ] **Run:** `cargo test --workspace` — Expected: only pre-existing postgres DB failures (require live database).
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add crates/domain/src/ports.rs crates/adapters/activitypub-base/src/service.rs crates/application/src/services/federation_event.rs
|
||||
git commit -m "feat: BoostRemoved → Undo(Announce) fan-out via OutboundFederationPort"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Follower-filtering DRY extraction in activitypub-base
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/adapters/activitypub-base/src/service.rs`
|
||||
|
||||
The repeated 20-line follower-filtering block appears in 7 methods. Extract it into a private async helper, then call it from the 6 content-broadcast methods. Leave `broadcast_actor_update` alone — it uses different filtering (no blocked-actor/domain check).
|
||||
|
||||
**Methods to update:** `broadcast_to_followers`, `broadcast_delete_to_followers`, `broadcast_update_to_followers`, `broadcast_add_to_followers`, `broadcast_undo_add_to_followers`, `broadcast_announce_to_followers`, `broadcast_undo_announce_to_followers`.
|
||||
|
||||
**Leave unchanged:** `broadcast_actor_update` (filters only on `FollowerStatus::Accepted`, no blocked checks).
|
||||
|
||||
- [ ] **Add private helper** to `impl ActivityPubService` — insert near the top of the impl block, after `request_data`:
|
||||
|
||||
```rust
|
||||
/// Returns `(local_actor, deduplicated_inboxes)` for all accepted followers,
|
||||
/// excluding blocked actors and blocked domains. Returns `None` if there are
|
||||
/// no eligible followers (caller should early-return `Ok(())`).
|
||||
async fn accepted_follower_inboxes(
|
||||
&self,
|
||||
data: &activitypub_federation::config::Data<FederationData>,
|
||||
local_user_id: uuid::Uuid,
|
||||
) -> anyhow::Result<Option<(crate::actors::DbActor, Vec<Url>)>> {
|
||||
let local_actor = get_local_actor(local_user_id, data)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
let followers = data.federation_repo.get_followers(local_user_id).await?;
|
||||
let blocked = data.federation_repo.get_blocked_actors(local_user_id).await.unwrap_or_default();
|
||||
let blocked_set: std::collections::HashSet<String> = blocked.into_iter().collect();
|
||||
let blocked_domains = data.federation_repo.get_blocked_domains().await.unwrap_or_default();
|
||||
let blocked_domain_set: std::collections::HashSet<String> =
|
||||
blocked_domains.into_iter().map(|d| d.domain).collect();
|
||||
|
||||
let accepted: Vec<_> = followers
|
||||
.into_iter()
|
||||
.filter(|f| f.status == FollowerStatus::Accepted)
|
||||
.filter(|f| !blocked_set.contains(&f.actor.url))
|
||||
.filter(|f| {
|
||||
let domain = url::Url::parse(&f.actor.inbox_url)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(|s| s.to_string()))
|
||||
.unwrap_or_default();
|
||||
!blocked_domain_set.contains(&domain)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if accepted.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some((local_actor, collect_inboxes(&accepted))))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Refactor each of the 7 methods** to use `accepted_follower_inboxes`.
|
||||
|
||||
For each method, replace the block that:
|
||||
1. Gets `local_actor`
|
||||
2. Gets followers + filtered inboxes
|
||||
|
||||
with:
|
||||
```rust
|
||||
let data = self.federation_config.to_request_data();
|
||||
let Some((local_actor, inboxes)) = self.accepted_follower_inboxes(&data, local_user_id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
```
|
||||
|
||||
Then use `local_actor` and `inboxes` directly in the activity construction (same as before).
|
||||
|
||||
The 7 methods are at these line numbers (before refactor — check actual lines in the file):
|
||||
- `broadcast_announce_to_followers`
|
||||
- `broadcast_undo_announce_to_followers` (just added in Task 1)
|
||||
- `broadcast_to_followers`
|
||||
- `broadcast_delete_to_followers`
|
||||
- `broadcast_update_to_followers`
|
||||
- `broadcast_add_to_followers`
|
||||
- `broadcast_undo_add_to_followers`
|
||||
|
||||
- [ ] **Run:** `cargo check -p activitypub-base` — Expected: no errors.
|
||||
|
||||
- [ ] **Run:** `cargo check --workspace` — Expected: no errors.
|
||||
|
||||
- [ ] **Run:** `cargo test --workspace` — Expected: same result as before (pre-existing postgres failures only).
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add crates/adapters/activitypub-base/src/service.rs
|
||||
git commit -m "refactor(activitypub-base): extract accepted_follower_inboxes helper — eliminate 7x duplicated filtering block"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- ✅ `broadcast_undo_announce` added to `OutboundFederationPort` (Task 1)
|
||||
- ✅ `broadcast_undo_announce_to_followers` sends `Undo { object: { type: "Announce", actor, object } }` to accepted, non-blocked followers (Task 1)
|
||||
- ✅ `FederationEventService` handles `BoostRemoved` with same ap_id construction as `BoostAdded` (Task 1)
|
||||
- ✅ 2 tests: local thought URL constructed, remote thought uses ap_id (Task 1)
|
||||
- ✅ `SpyPort` has separate `undo_announced` vec (Task 1)
|
||||
- ✅ `accepted_follower_inboxes` helper extracts the 20-line filtering block (Task 2)
|
||||
- ✅ Helper used in 7 content-broadcast methods (Task 2)
|
||||
- ✅ `broadcast_actor_update` NOT touched — it uses different filtering (Task 2)
|
||||
|
||||
**Placeholder scan:** None.
|
||||
|
||||
**Type consistency:**
|
||||
- `UndoActivity` is already defined in `activities.rs` with `object: serde_json::Value` — no new activity type needed
|
||||
- `broadcast_undo_announce_to_followers(uuid::Uuid, url::Url)` — same signature pattern as `broadcast_announce_to_followers`
|
||||
- `accepted_follower_inboxes` returns `Option<(DbActor, Vec<Url>)>` — caller destructures with `let Some(...) = ... else { return Ok(()) }`
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,562 +0,0 @@
|
||||
# Merge Readiness Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Close the remaining gaps between v2 and v1 so the new Rust backend can replace the old one. Five tasks: fix feed response hydration, wire missing follower/following routes, add user listing endpoints, add popular tags, harden config (HOST, CORS, rate limiting).
|
||||
|
||||
**Architecture:** All changes are in `presentation`, `domain/ports`, `adapters/postgres`, and `bootstrap`. No changes to `application` or `worker`.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
```
|
||||
Task 1 — Feed hydration:
|
||||
Modify: crates/presentation/src/handlers/feed.rs ← add to_thought_response helper, fix 4 handlers
|
||||
Modify: crates/presentation/src/handlers/auth.rs ← move/export to_feed_entry helper if needed
|
||||
|
||||
Task 2 — Wire follower/following routes:
|
||||
Modify: crates/presentation/src/routes.rs ← add 2 routes
|
||||
|
||||
Task 3 — User listing + count:
|
||||
Modify: crates/domain/src/ports.rs ← add count() to UserRepository
|
||||
Modify: crates/adapters/postgres/src/user.rs ← implement count()
|
||||
Modify: crates/domain/src/testing.rs ← add count() to TestStore
|
||||
Modify: crates/presentation/src/handlers/users.rs ← add get_users, get_user_count handlers
|
||||
Modify: crates/presentation/src/routes.rs ← add 2 routes
|
||||
|
||||
Task 4 — Popular tags:
|
||||
Modify: crates/domain/src/ports.rs ← add popular_tags() to TagRepository
|
||||
Modify: crates/adapters/postgres/src/tag.rs ← implement popular_tags()
|
||||
Modify: crates/domain/src/testing.rs ← add popular_tags() to TestStore
|
||||
Modify: crates/presentation/src/handlers/feed.rs ← add get_popular_tags handler
|
||||
Modify: crates/presentation/src/routes.rs ← add 1 route (before /tags/{name})
|
||||
|
||||
Task 5 — Config: HOST, CORS_ORIGINS, RATE_LIMIT:
|
||||
Modify: crates/bootstrap/src/config.rs ← 3 new fields
|
||||
Modify: crates/bootstrap/src/main.rs ← use HOST, CORS layer, rate limit layer
|
||||
Modify: crates/bootstrap/Cargo.toml ← add tower-governor
|
||||
Modify: .env.example ← document new vars
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Fix feed response hydration
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/presentation/src/handlers/feed.rs`
|
||||
|
||||
**Problem:** `home_feed` and `public_feed` return only UUIDs. `user_thoughts_handler` and `tag_thoughts_handler` are missing `author`, `in_reply_to_id`, `sensitive`, `content_warning`, viewer flags. All four need to use `ThoughtResponse`.
|
||||
|
||||
The `ThoughtResponse` DTO in `api-types` already has every needed field. `FeedEntry` in domain already carries `like_count`, `boost_count`, `reply_count`, `liked_by_viewer`, `boosted_by_viewer`. The conversion is straightforward.
|
||||
|
||||
- [ ] **Add `to_thought_response` helper** at the top of `feed.rs` (after existing imports). This is a private free function:
|
||||
|
||||
```rust
|
||||
use api_types::responses::ThoughtResponse;
|
||||
|
||||
fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse {
|
||||
ThoughtResponse {
|
||||
id: e.thought.id.as_uuid(),
|
||||
content: e.thought.content.as_str().to_string(),
|
||||
author: crate::handlers::auth::to_user_response(&e.author),
|
||||
in_reply_to_id: e.thought.in_reply_to_id.as_ref().map(|id| id.as_uuid()),
|
||||
visibility: e.thought.visibility.as_str().to_string(),
|
||||
content_warning: e.thought.content_warning.clone(),
|
||||
sensitive: e.thought.sensitive,
|
||||
like_count: e.like_count,
|
||||
boost_count: e.boost_count,
|
||||
reply_count: e.reply_count,
|
||||
liked_by_viewer: e.liked_by_viewer,
|
||||
boosted_by_viewer: e.boosted_by_viewer,
|
||||
created_at: e.thought.created_at,
|
||||
updated_at: e.thought.updated_at,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Fix `home_feed`** — replace the UUID-only mapping:
|
||||
|
||||
```rust
|
||||
pub async fn home_feed(
|
||||
State(s): State<AppState>,
|
||||
AuthUser(uid): AuthUser,
|
||||
Query(q): Query<PaginationQuery>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let page = PageParams { page: q.page(), per_page: q.per_page() };
|
||||
let result = get_home_feed(&*s.feed, &*s.follows, &uid, page).await?;
|
||||
Ok(Json(serde_json::json!({
|
||||
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
|
||||
"total": result.total,
|
||||
"page": result.page,
|
||||
"per_page": result.per_page,
|
||||
})))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Fix `public_feed`** — same pattern:
|
||||
|
||||
```rust
|
||||
pub async fn public_feed(
|
||||
State(s): State<AppState>,
|
||||
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_public_feed(&*s.feed, viewer.as_ref(), page).await?;
|
||||
Ok(Json(serde_json::json!({
|
||||
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
|
||||
"total": result.total,
|
||||
"page": result.page,
|
||||
"per_page": result.per_page,
|
||||
})))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Fix `user_thoughts_handler`** — replace the partial mapping with `to_thought_response`:
|
||||
|
||||
```rust
|
||||
pub async fn user_thoughts_handler(
|
||||
State(s): State<AppState>,
|
||||
Path(username): Path<String>,
|
||||
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?;
|
||||
Ok(Json(serde_json::json!({
|
||||
"total": result.total,
|
||||
"page": result.page,
|
||||
"per_page": result.per_page,
|
||||
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Fix `tag_thoughts_handler`** — same:
|
||||
|
||||
```rust
|
||||
pub async fn tag_thoughts_handler(
|
||||
State(s): State<AppState>,
|
||||
Path(tag_name): Path<String>,
|
||||
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.tags, &tag_name, page).await?;
|
||||
Ok(Json(serde_json::json!({
|
||||
"tag": tag_name,
|
||||
"total": result.total,
|
||||
"page": result.page,
|
||||
"per_page": result.per_page,
|
||||
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
```
|
||||
|
||||
NOTE: `get_by_tag` returns `Paginated<Thought>`, not `Paginated<FeedEntry>` — it won't have author or counts. Check the use case signature. If it returns `Paginated<Thought>`, map manually keeping available fields only (id, content, visibility, dates). If it returns `Paginated<FeedEntry>`, use `to_thought_response`.
|
||||
|
||||
- [ ] **Run:** `cargo check -p presentation` — Expected: no errors.
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add crates/presentation/src/handlers/feed.rs
|
||||
git commit -m "fix(presentation): hydrate feed responses with full ThoughtResponse — remove UUID-only stubs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Wire follower/following REST routes
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/presentation/src/routes.rs`
|
||||
|
||||
`get_followers_handler` and `get_following_handler` already exist in `feed.rs` (lines 75–80). The AP routes own `/users/{username}/followers` and `/users/{username}/following`. Wire the REST handlers at non-conflicting paths:
|
||||
|
||||
- [ ] **Add two routes to `api_routes`** in `routes.rs`, in the users section (before `/thoughts`):
|
||||
|
||||
```rust
|
||||
.route("/users/{username}/follower-list", get(feed::get_followers_handler))
|
||||
.route("/users/{username}/following-list", get(feed::get_following_handler))
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo check -p presentation` — Expected: no errors.
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add crates/presentation/src/routes.rs
|
||||
git commit -m "feat(presentation): wire GET /users/{username}/follower-list and /following-list"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: User listing + count
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/domain/src/ports.rs`
|
||||
- Modify: `crates/adapters/postgres/src/user.rs`
|
||||
- Modify: `crates/domain/src/testing.rs`
|
||||
- Modify: `crates/presentation/src/handlers/users.rs`
|
||||
- Modify: `crates/presentation/src/routes.rs`
|
||||
|
||||
- [ ] **Add `count()` to `UserRepository`** in `crates/domain/src/ports.rs`:
|
||||
|
||||
```rust
|
||||
async fn count(&self) -> Result<i64, DomainError>;
|
||||
```
|
||||
|
||||
- [ ] **Implement `count()` in postgres** — find `impl UserRepository for PgUserRepository` in `crates/adapters/postgres/src/user.rs` and add:
|
||||
|
||||
```rust
|
||||
async fn count(&self) -> Result<i64, DomainError> {
|
||||
let row = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users WHERE local = true")
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
Ok(row)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Implement `count()` in TestStore** in `crates/domain/src/testing.rs`:
|
||||
|
||||
```rust
|
||||
async fn count(&self) -> Result<i64, DomainError> {
|
||||
Ok(self.users.lock().unwrap().iter().filter(|u| u.local).count() as i64)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Add handlers to `crates/presentation/src/handlers/users.rs`:**
|
||||
|
||||
```rust
|
||||
use domain::models::feed::UserSummary;
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/users",
|
||||
params(
|
||||
("q" = Option<String>, Query, description = "Search query"),
|
||||
PaginationQuery,
|
||||
),
|
||||
responses((status = 200, description = "User list"))
|
||||
)]
|
||||
pub async fn get_users(
|
||||
State(s): State<AppState>,
|
||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
use domain::models::feed::PageParams;
|
||||
let page = params.get("page").and_then(|v| v.parse().ok()).unwrap_or(1u64);
|
||||
let per_page = params.get("per_page").and_then(|v| v.parse().ok()).unwrap_or(20u64);
|
||||
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 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 total = all.len() as i64;
|
||||
let start = ((page - 1) * per_page) as usize;
|
||||
let items: Vec<_> = all.into_iter().skip(start).take(per_page as usize)
|
||||
.map(|u| serde_json::json!({
|
||||
"id": u.id.as_uuid(),
|
||||
"username": u.username,
|
||||
"display_name": u.display_name,
|
||||
"avatar_url": u.avatar_url,
|
||||
"bio": u.bio,
|
||||
"thought_count": u.thought_count,
|
||||
"follower_count": u.follower_count,
|
||||
"following_count": u.following_count,
|
||||
}))
|
||||
.collect();
|
||||
Ok(Json(serde_json::json!({ "items": items, "total": total, "page": page, "per_page": per_page })))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/users/count",
|
||||
responses((status = 200, description = "Local user count"))
|
||||
)]
|
||||
pub async fn get_user_count(
|
||||
State(s): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let count = s.users.count().await?;
|
||||
Ok(Json(serde_json::json!({ "count": count })))
|
||||
}
|
||||
```
|
||||
|
||||
Note: `get_users` needs `use api_types::requests::PaginationQuery;` added to imports if not already there. Check the file's existing imports.
|
||||
|
||||
- [ ] **Add routes to `routes.rs`** — add BEFORE `/users/me` (static paths must come before parameterised):
|
||||
|
||||
```rust
|
||||
.route("/users", get(users::get_users))
|
||||
.route("/users/count", get(users::get_user_count))
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo check --workspace` — Expected: no errors.
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add crates/domain/src/ports.rs \
|
||||
crates/adapters/postgres/src/user.rs \
|
||||
crates/domain/src/testing.rs \
|
||||
crates/presentation/src/handlers/users.rs \
|
||||
crates/presentation/src/routes.rs
|
||||
git commit -m "feat: GET /users (search/list) and GET /users/count"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Popular tags
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/domain/src/ports.rs`
|
||||
- Modify: `crates/adapters/postgres/src/tag.rs`
|
||||
- Modify: `crates/domain/src/testing.rs`
|
||||
- Modify: `crates/presentation/src/handlers/feed.rs`
|
||||
- Modify: `crates/presentation/src/routes.rs`
|
||||
|
||||
- [ ] **Add `popular_tags()` to `TagRepository`** in `crates/domain/src/ports.rs`:
|
||||
|
||||
```rust
|
||||
/// Returns (tag_name, thought_count) pairs, most-used first.
|
||||
async fn popular_tags(&self, limit: usize) -> Result<Vec<(String, i64)>, DomainError>;
|
||||
```
|
||||
|
||||
- [ ] **Implement `popular_tags()` in postgres** — find `impl TagRepository for PgTagRepository` in `crates/adapters/postgres/src/tag.rs` and add:
|
||||
|
||||
```rust
|
||||
async fn popular_tags(&self, limit: usize) -> Result<Vec<(String, i64)>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, (String, i64)>(
|
||||
"SELECT t.name, COUNT(tt.thought_id) AS thought_count
|
||||
FROM tags t
|
||||
JOIN thought_tags tt ON t.id = tt.tag_id
|
||||
GROUP BY t.id, t.name
|
||||
ORDER BY thought_count DESC
|
||||
LIMIT $1"
|
||||
)
|
||||
.bind(limit as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
Ok(rows)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Implement `popular_tags()` in TestStore** in `crates/domain/src/testing.rs`:
|
||||
|
||||
```rust
|
||||
async fn popular_tags(&self, _limit: usize) -> Result<Vec<(String, i64)>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Add `get_popular_tags` handler** to `crates/presentation/src/handlers/feed.rs`:
|
||||
|
||||
```rust
|
||||
pub async fn get_popular_tags(
|
||||
State(s): State<AppState>,
|
||||
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?;
|
||||
Ok(Json(serde_json::json!({
|
||||
"tags": tags.iter().map(|(name, count)| serde_json::json!({
|
||||
"name": name,
|
||||
"thought_count": count,
|
||||
})).collect::<Vec<_>>()
|
||||
})))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Wire `GET /tags/popular` in `routes.rs`** — add BEFORE `/tags/{name}` (otherwise `popular` is captured as the `{name}` param):
|
||||
|
||||
```rust
|
||||
.route("/tags/popular", get(feed::get_popular_tags))
|
||||
.route("/tags/{name}", get(feed::tag_thoughts_handler))
|
||||
```
|
||||
|
||||
The existing `.route("/tags/{name}", ...)` line can stay — just add the popular route immediately before it.
|
||||
|
||||
- [ ] **Run:** `cargo check --workspace` — Expected: no errors.
|
||||
|
||||
- [ ] **Run unit tests:** `cargo test --workspace --exclude postgres --exclude postgres-federation --exclude postgres-search` — Expected: all pass.
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add crates/domain/src/ports.rs \
|
||||
crates/adapters/postgres/src/tag.rs \
|
||||
crates/domain/src/testing.rs \
|
||||
crates/presentation/src/handlers/feed.rs \
|
||||
crates/presentation/src/routes.rs
|
||||
git commit -m "feat: GET /tags/popular — top tags by usage count"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Config — HOST, CORS_ORIGINS, RATE_LIMIT
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/bootstrap/src/config.rs`
|
||||
- Modify: `crates/bootstrap/src/main.rs`
|
||||
- Modify: `crates/bootstrap/Cargo.toml`
|
||||
- Modify: `.env.example`
|
||||
|
||||
- [ ] **Add `tower-governor` to `crates/bootstrap/Cargo.toml`:**
|
||||
|
||||
```toml
|
||||
tower-governor = "0.6"
|
||||
```
|
||||
|
||||
- [ ] **Add three fields to `Config` in `crates/bootstrap/src/config.rs`:**
|
||||
|
||||
```rust
|
||||
pub struct Config {
|
||||
pub database_url: String,
|
||||
pub jwt_secret: String,
|
||||
pub base_url: String,
|
||||
pub nats_url: Option<String>,
|
||||
pub port: u16,
|
||||
pub host: String,
|
||||
pub allow_registration: bool,
|
||||
pub debug: bool,
|
||||
/// Comma-separated allowed origins, or "*" for permissive. Default: "*".
|
||||
pub cors_origins: String,
|
||||
/// Max requests per minute per IP. None = disabled.
|
||||
pub rate_limit: Option<u32>,
|
||||
}
|
||||
```
|
||||
|
||||
In `from_env()` add:
|
||||
```rust
|
||||
host: std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".into()),
|
||||
cors_origins: std::env::var("CORS_ORIGINS").unwrap_or_else(|_| "*".into()),
|
||||
rate_limit: std::env::var("RATE_LIMIT").ok().and_then(|v| v.parse().ok()),
|
||||
```
|
||||
|
||||
- [ ] **Update `crates/bootstrap/src/main.rs`:**
|
||||
|
||||
```rust
|
||||
mod config;
|
||||
mod factory;
|
||||
|
||||
use std::sync::Arc;
|
||||
use http::HeaderValue;
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let cfg = config::Config::from_env();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
let infra = factory::build(&cfg).await;
|
||||
|
||||
// CORS
|
||||
let cors = if cfg.cors_origins.trim() == "*" {
|
||||
CorsLayer::permissive()
|
||||
} else {
|
||||
let origins: Vec<HeaderValue> = cfg.cors_origins
|
||||
.split(',')
|
||||
.map(|o| o.trim())
|
||||
.filter_map(|o| o.parse().ok())
|
||||
.collect();
|
||||
CorsLayer::new()
|
||||
.allow_origin(AllowOrigin::list(origins))
|
||||
.allow_methods(tower_http::cors::Any)
|
||||
.allow_headers(tower_http::cors::Any)
|
||||
};
|
||||
|
||||
let app = presentation::routes::router(&infra.fed_config)
|
||||
.with_state(infra.state)
|
||||
.layer(cors);
|
||||
|
||||
// Rate limiting (optional)
|
||||
let app = if let Some(rate_limit) = cfg.rate_limit {
|
||||
use tower_governor::{GovernorLayer, GovernorConfigBuilder};
|
||||
let governor_config = Arc::new(
|
||||
GovernorConfigBuilder::default()
|
||||
.per_millisecond(60_000 / rate_limit as u64)
|
||||
.burst_size(rate_limit)
|
||||
.use_headers()
|
||||
.finish()
|
||||
.expect("valid rate limit config"),
|
||||
);
|
||||
let limiter = governor_config.limiter().clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
limiter.retain_recent();
|
||||
}
|
||||
});
|
||||
app.layer(GovernorLayer { config: governor_config })
|
||||
} else {
|
||||
app
|
||||
};
|
||||
|
||||
let addr = format!("{}:{}", cfg.host, cfg.port);
|
||||
tracing::info!("Listening on {addr}");
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
Note: `tower-governor`'s `GovernorLayer` API may differ slightly — check the actual 0.6.x docs and adjust. The `GovernorConfigBuilder` might use `.per_second()` instead of `.per_millisecond()`. Verify and use whichever method produces the desired requests-per-minute rate.
|
||||
|
||||
Note 2: Axum `Router::layer` returns the same type when adding a standard layer. `GovernorLayer` returns a different type. If the type system complains, wrap the app in `tower::ServiceBuilder` or use `.layer(tower::ServiceBuilder::new().layer(GovernorLayer { ... }).into_inner())`.
|
||||
|
||||
- [ ] **Update `.env.example`** — add the three new vars:
|
||||
|
||||
```env
|
||||
# Optional
|
||||
HOST=0.0.0.0
|
||||
PORT=3000
|
||||
ALLOW_REGISTRATION=true
|
||||
RUST_ENV=development
|
||||
|
||||
# CORS — comma-separated origins, or * for permissive (default: *)
|
||||
CORS_ORIGINS=*
|
||||
# CORS_ORIGINS=https://your-nextjs-app.example.com
|
||||
|
||||
# Rate limiting — max requests per minute per IP (disabled by default)
|
||||
# RATE_LIMIT=60
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo check -p bootstrap` — Expected: no errors (fix tower-governor API if needed).
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add crates/bootstrap/ .env.example
|
||||
git commit -m "feat(bootstrap): configurable HOST, CORS_ORIGINS, and optional rate limiting"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- ✅ `home_feed` / `public_feed` return full `ThoughtResponse` (Task 1)
|
||||
- ✅ `user_thoughts_handler` / `tag_thoughts_handler` use `to_thought_response` (Task 1)
|
||||
- ✅ `GET /users/{username}/follower-list` and `/following-list` wired (Task 2)
|
||||
- ✅ `GET /users` (search + list) + `GET /users/count` (Task 3)
|
||||
- ✅ `UserRepository::count()` in port + postgres + TestStore (Task 3)
|
||||
- ✅ `GET /tags/popular` wired before `/tags/{name}` (Task 4)
|
||||
- ✅ `TagRepository::popular_tags()` in port + postgres + TestStore (Task 4)
|
||||
- ✅ `HOST`, `CORS_ORIGINS`, `RATE_LIMIT` in Config (Task 5)
|
||||
- ✅ CORS layer uses configured origins (Task 5)
|
||||
- ✅ Rate limiting via tower-governor, disabled by default (Task 5)
|
||||
|
||||
**Placeholder scan:** None.
|
||||
|
||||
**Type consistency:**
|
||||
- `to_thought_response` maps `FeedEntry` → `ThoughtResponse` — both types confirmed in source
|
||||
- `tag_thoughts_handler` uses `get_by_tag` which returns `Paginated<Thought>` — verify whether it returns `Thought` or `FeedEntry` and adjust the mapping accordingly
|
||||
- `popular_tags()` returns `Vec<(String, i64)>` — matches the SQL query's two columns
|
||||
- `GovernorLayer` API — implementer must verify against installed tower-governor version
|
||||
@@ -1,822 +0,0 @@
|
||||
# OpenAPI / Swagger Docs Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add utoipa OpenAPI documentation to all REST handlers, served at `/docs` (Swagger UI) and `/scalar` — mirroring the movies-diary pattern.
|
||||
|
||||
**Architecture:** `#[utoipa::path]` annotations go on handler functions. `#[derive(utoipa::ToSchema)]` goes on api-types DTOs. Feature-grouped doc structs in `presentation/src/openapi/` assemble the spec. `openapi::serve(router)` merges Swagger UI and Scalar into the axum router. Handlers returning `serde_json::Value` use `inline((status = 200, description = "..."))` or reference inline schema objects.
|
||||
|
||||
**Tech Stack:** utoipa 5.5, utoipa-scalar 0.3, utoipa-swagger-ui 9.0
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
```
|
||||
Modify: crates/presentation/Cargo.toml ← add utoipa, utoipa-scalar, utoipa-swagger-ui
|
||||
Modify: crates/api-types/Cargo.toml ← add utoipa with uuid feature
|
||||
Modify: crates/api-types/src/requests.rs ← add #[derive(ToSchema, IntoParams)]
|
||||
Modify: crates/api-types/src/responses.rs ← add #[derive(ToSchema)]
|
||||
Create: crates/presentation/src/openapi/mod.rs ← assembles all doc structs, serves /docs + /scalar
|
||||
Create: crates/presentation/src/openapi/auth.rs
|
||||
Create: crates/presentation/src/openapi/users.rs
|
||||
Create: crates/presentation/src/openapi/thoughts.rs
|
||||
Create: crates/presentation/src/openapi/feed.rs
|
||||
Create: crates/presentation/src/openapi/social.rs
|
||||
Create: crates/presentation/src/openapi/notifications.rs
|
||||
Create: crates/presentation/src/openapi/api_keys.rs
|
||||
Modify: crates/presentation/src/handlers/auth.rs ← add #[utoipa::path] to 2 handlers
|
||||
Modify: crates/presentation/src/handlers/users.rs ← add #[utoipa::path] to 3 handlers
|
||||
Modify: crates/presentation/src/handlers/thoughts.rs ← add #[utoipa::path] to 5 handlers
|
||||
Modify: crates/presentation/src/handlers/feed.rs ← add #[utoipa::path] to 5 handlers
|
||||
Modify: crates/presentation/src/handlers/social.rs ← add #[utoipa::path] to 10 handlers
|
||||
Modify: crates/presentation/src/handlers/notifications.rs ← add #[utoipa::path] to 3 handlers
|
||||
Modify: crates/presentation/src/handlers/api_keys.rs ← add #[utoipa::path] to 3 handlers
|
||||
Modify: crates/presentation/src/handlers/health.rs ← add #[utoipa::path]
|
||||
Modify: crates/presentation/src/handlers/mod.rs ← add pub mod openapi
|
||||
Modify: crates/presentation/src/routes.rs ← call openapi::serve(router)
|
||||
Modify: crates/presentation/src/lib.rs ← pub mod openapi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Dependencies + ToSchema on api-types
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/presentation/Cargo.toml`
|
||||
- Modify: `crates/api-types/Cargo.toml`
|
||||
- Modify: `crates/api-types/src/requests.rs`
|
||||
- Modify: `crates/api-types/src/responses.rs`
|
||||
|
||||
- [ ] **Add deps to `crates/presentation/Cargo.toml`:**
|
||||
|
||||
```toml
|
||||
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"] }
|
||||
```
|
||||
|
||||
- [ ] **Add dep to `crates/api-types/Cargo.toml`:**
|
||||
|
||||
```toml
|
||||
utoipa = { version = "5.5.0", features = ["uuid", "chrono"] }
|
||||
```
|
||||
|
||||
- [ ] **Add `#[derive(utoipa::ToSchema)]` and `#[derive(utoipa::IntoParams)]` to `crates/api-types/src/requests.rs`:**
|
||||
|
||||
Replace the file with:
|
||||
|
||||
```rust
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct RegisterRequest {
|
||||
/// Username (1-32 chars, alphanumeric + underscore)
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct LoginRequest {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct CreateThoughtRequest {
|
||||
/// Up to 128 characters
|
||||
pub content: String,
|
||||
pub in_reply_to_id: Option<Uuid>,
|
||||
/// One of: "public", "followers", "unlisted", "direct"
|
||||
pub visibility: Option<String>,
|
||||
pub content_warning: Option<String>,
|
||||
pub sensitive: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct EditThoughtRequest {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct UpdateProfileRequest {
|
||||
pub display_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub header_url: Option<String>,
|
||||
pub custom_css: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct SetTopFriendsRequest {
|
||||
/// Ordered list of user UUIDs, max 8
|
||||
pub friend_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct CreateApiKeyRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::IntoParams)]
|
||||
pub struct PaginationQuery {
|
||||
pub page: Option<u64>,
|
||||
pub per_page: Option<u64>,
|
||||
}
|
||||
impl PaginationQuery {
|
||||
pub fn page(&self) -> u64 { self.page.unwrap_or(1).max(1) }
|
||||
pub fn per_page(&self) -> u64 { self.per_page.unwrap_or(20).min(100) }
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::IntoParams)]
|
||||
pub struct SearchQuery {
|
||||
pub q: String,
|
||||
pub page: Option<u64>,
|
||||
pub per_page: Option<u64>,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Add `#[derive(utoipa::ToSchema)]` to `crates/api-types/src/responses.rs`:**
|
||||
|
||||
Replace the file with:
|
||||
|
||||
```rust
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
pub struct AuthResponse {
|
||||
pub token: String,
|
||||
pub user: UserResponse,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, utoipa::ToSchema)]
|
||||
pub struct UserResponse {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub display_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub header_url: Option<String>,
|
||||
pub local: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, utoipa::ToSchema)]
|
||||
pub struct ThoughtResponse {
|
||||
pub id: Uuid,
|
||||
pub content: String,
|
||||
pub author: UserResponse,
|
||||
pub in_reply_to_id: Option<Uuid>,
|
||||
pub visibility: String,
|
||||
pub content_warning: Option<String>,
|
||||
pub sensitive: bool,
|
||||
pub like_count: i64,
|
||||
pub boost_count: i64,
|
||||
pub reply_count: i64,
|
||||
pub liked_by_viewer: bool,
|
||||
pub boosted_by_viewer: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
pub struct PagedResponse<T: Serialize + utoipa::ToSchema> {
|
||||
pub items: Vec<T>,
|
||||
pub total: i64,
|
||||
pub page: u64,
|
||||
pub per_page: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
pub struct ApiKeyResponse {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
pub struct NotificationResponse {
|
||||
pub id: Uuid,
|
||||
pub notification_type: String,
|
||||
pub from_user: Option<UserResponse>,
|
||||
pub thought_id: Option<Uuid>,
|
||||
pub read: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
pub struct ErrorResponse {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
pub struct CreatedApiKeyResponse {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
/// Raw API key — shown only once at creation
|
||||
pub key: String,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo check -p api-types` — Expected: no errors.
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add crates/presentation/Cargo.toml crates/api-types/
|
||||
git commit -m "feat(api-types): add utoipa ToSchema and IntoParams derives"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Annotate handlers + create openapi modules
|
||||
|
||||
**Files:** All handler files + `crates/presentation/src/openapi/`
|
||||
|
||||
- [ ] **Add `#[utoipa::path]` to `crates/presentation/src/handlers/auth.rs`:**
|
||||
|
||||
```rust
|
||||
#[utoipa::path(
|
||||
post, path = "/auth/register",
|
||||
request_body = RegisterRequest,
|
||||
responses(
|
||||
(status = 201, description = "User registered", body = AuthResponse),
|
||||
(status = 409, description = "Username or email taken", body = ErrorResponse),
|
||||
(status = 422, description = "Invalid input", body = ErrorResponse),
|
||||
)
|
||||
)]
|
||||
pub async fn post_register(...) { ... }
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/auth/login",
|
||||
request_body = LoginRequest,
|
||||
responses(
|
||||
(status = 200, description = "Login successful", body = AuthResponse),
|
||||
(status = 401, description = "Invalid credentials", body = ErrorResponse),
|
||||
)
|
||||
)]
|
||||
pub async fn post_login(...) { ... }
|
||||
```
|
||||
|
||||
- [ ] **Add `#[utoipa::path]` to `crates/presentation/src/handlers/users.rs`:**
|
||||
|
||||
```rust
|
||||
#[utoipa::path(
|
||||
get, path = "/users/me",
|
||||
responses(
|
||||
(status = 200, body = UserResponse),
|
||||
(status = 401, description = "Unauthorized", body = ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_me(...) { ... }
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/users/{username}",
|
||||
params(("username" = String, Path, description = "Username")),
|
||||
responses(
|
||||
(status = 200, body = UserResponse),
|
||||
(status = 404, description = "User not found", body = ErrorResponse),
|
||||
)
|
||||
)]
|
||||
pub async fn get_user(...) { ... }
|
||||
|
||||
#[utoipa::path(
|
||||
patch, path = "/users/me",
|
||||
request_body = UpdateProfileRequest,
|
||||
responses(
|
||||
(status = 200, body = UserResponse),
|
||||
(status = 401, description = "Unauthorized", body = ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn patch_profile(...) { ... }
|
||||
```
|
||||
|
||||
- [ ] **Add `#[utoipa::path]` to `crates/presentation/src/handlers/thoughts.rs`:**
|
||||
|
||||
```rust
|
||||
#[utoipa::path(
|
||||
post, path = "/thoughts",
|
||||
request_body = CreateThoughtRequest,
|
||||
responses(
|
||||
(status = 201, description = "Thought created"),
|
||||
(status = 401, description = "Unauthorized", body = ErrorResponse),
|
||||
(status = 422, description = "Content too long", body = ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn post_thought(...) { ... }
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/thoughts/{id}",
|
||||
params(("id" = Uuid, Path, description = "Thought ID")),
|
||||
responses(
|
||||
(status = 200, description = "Thought with author info"),
|
||||
(status = 404, description = "Not found", body = ErrorResponse),
|
||||
)
|
||||
)]
|
||||
pub async fn get_thought_handler(...) { ... }
|
||||
|
||||
#[utoipa::path(
|
||||
patch, path = "/thoughts/{id}",
|
||||
params(("id" = Uuid, Path, description = "Thought ID")),
|
||||
request_body = EditThoughtRequest,
|
||||
responses(
|
||||
(status = 204, description = "Updated"),
|
||||
(status = 401, description = "Unauthorized", body = ErrorResponse),
|
||||
(status = 404, description = "Not found or not owner", body = ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn patch_thought(...) { ... }
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/thoughts/{id}",
|
||||
params(("id" = Uuid, Path, description = "Thought ID")),
|
||||
responses(
|
||||
(status = 204, description = "Deleted"),
|
||||
(status = 401, description = "Unauthorized", body = ErrorResponse),
|
||||
(status = 404, description = "Not found or not owner", body = ErrorResponse),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_thought_handler(...) { ... }
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/thoughts/{id}/thread",
|
||||
params(("id" = Uuid, Path, description = "Root thought ID")),
|
||||
responses(
|
||||
(status = 200, description = "Thread (root + replies)"),
|
||||
)
|
||||
)]
|
||||
pub async fn get_thread_handler(...) { ... }
|
||||
```
|
||||
|
||||
- [ ] **Add `#[utoipa::path]` to `crates/presentation/src/handlers/feed.rs`:**
|
||||
|
||||
```rust
|
||||
#[utoipa::path(
|
||||
get, path = "/feed",
|
||||
params(PaginationQuery),
|
||||
responses((status = 200, description = "Home feed (followed users' thoughts)")),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn home_feed(...) { ... }
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/feed/public",
|
||||
params(PaginationQuery),
|
||||
responses((status = 200, description = "Public feed (all local thoughts)"))
|
||||
)]
|
||||
pub async fn public_feed(...) { ... }
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/search",
|
||||
params(SearchQuery),
|
||||
responses((status = 200, description = "Search results: {thoughts, users}"))
|
||||
)]
|
||||
pub async fn search_handler(...) { ... }
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/users/{username}/thoughts",
|
||||
params(
|
||||
("username" = String, Path, description = "Username"),
|
||||
PaginationQuery,
|
||||
),
|
||||
responses((status = 200, description = "User's public thoughts")),
|
||||
)]
|
||||
pub async fn user_thoughts_handler(...) { ... }
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/tags/{name}",
|
||||
params(
|
||||
("name" = String, Path, description = "Tag name"),
|
||||
PaginationQuery,
|
||||
),
|
||||
responses((status = 200, description = "Thoughts with this tag")),
|
||||
)]
|
||||
pub async fn tag_thoughts_handler(...) { ... }
|
||||
```
|
||||
|
||||
- [ ] **Add `#[utoipa::path]` to `crates/presentation/src/handlers/social.rs`:**
|
||||
|
||||
```rust
|
||||
#[utoipa::path(post, path = "/thoughts/{id}/like", params(("id" = Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Liked")), security(("bearer_auth" = [])))]
|
||||
pub async fn post_like(...) { ... }
|
||||
|
||||
#[utoipa::path(delete, path = "/thoughts/{id}/like", params(("id" = Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Unliked")), security(("bearer_auth" = [])))]
|
||||
pub async fn delete_like(...) { ... }
|
||||
|
||||
#[utoipa::path(post, path = "/thoughts/{id}/boost", params(("id" = Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Boosted")), security(("bearer_auth" = [])))]
|
||||
pub async fn post_boost(...) { ... }
|
||||
|
||||
#[utoipa::path(delete, path = "/thoughts/{id}/boost", params(("id" = Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Unboosted")), security(("bearer_auth" = [])))]
|
||||
pub async fn delete_boost(...) { ... }
|
||||
|
||||
#[utoipa::path(post, path = "/users/{id}/follow", params(("id" = Uuid, Path, description = "User ID")), responses((status = 204, description = "Following")), security(("bearer_auth" = [])))]
|
||||
pub async fn post_follow(...) { ... }
|
||||
|
||||
#[utoipa::path(delete, path = "/users/{id}/follow", params(("id" = Uuid, Path, description = "User ID")), responses((status = 204, description = "Unfollowed")), security(("bearer_auth" = [])))]
|
||||
pub async fn delete_follow(...) { ... }
|
||||
|
||||
#[utoipa::path(post, path = "/users/{id}/block", params(("id" = Uuid, Path, description = "User ID")), responses((status = 204, description = "Blocked")), security(("bearer_auth" = [])))]
|
||||
pub async fn post_block(...) { ... }
|
||||
|
||||
#[utoipa::path(delete, path = "/users/{id}/block", params(("id" = Uuid, Path, description = "User ID")), responses((status = 204, description = "Unblocked")), security(("bearer_auth" = [])))]
|
||||
pub async fn delete_block(...) { ... }
|
||||
|
||||
#[utoipa::path(
|
||||
put, path = "/users/me/top-friends",
|
||||
request_body = SetTopFriendsRequest,
|
||||
responses((status = 204, description = "Top friends updated")),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn put_top_friends(...) { ... }
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/users/{username}/top-friends",
|
||||
params(("username" = String, Path, description = "Username")),
|
||||
responses((status = 200, description = "Top friends list"))
|
||||
)]
|
||||
pub async fn get_top_friends_handler(...) { ... }
|
||||
```
|
||||
|
||||
- [ ] **Add `#[utoipa::path]` to `crates/presentation/src/handlers/notifications.rs`:**
|
||||
|
||||
```rust
|
||||
#[utoipa::path(
|
||||
get, path = "/notifications",
|
||||
responses((status = 200, description = "Notification summary")),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn list_notifications(...) { ... }
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/notifications/{id}/read",
|
||||
params(("id" = Uuid, Path, description = "Notification ID")),
|
||||
responses((status = 204, description = "Marked read")),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn mark_notification_read(...) { ... }
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/notifications/read-all",
|
||||
responses((status = 204, description = "All marked read")),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn mark_all_read(...) { ... }
|
||||
```
|
||||
|
||||
- [ ] **Add `#[utoipa::path]` to `crates/presentation/src/handlers/api_keys.rs`:**
|
||||
|
||||
```rust
|
||||
#[utoipa::path(
|
||||
get, path = "/api-keys",
|
||||
responses((status = 200, description = "List of API keys", body = Vec<ApiKeyResponse>)),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_api_keys(...) { ... }
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api-keys",
|
||||
request_body = CreateApiKeyRequest,
|
||||
responses((status = 200, description = "Created API key — raw key shown once", body = CreatedApiKeyResponse)),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn post_api_key(...) { ... }
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api-keys/{id}",
|
||||
params(("id" = Uuid, Path, description = "API key ID")),
|
||||
responses((status = 204, description = "Deleted")),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_api_key_handler(...) { ... }
|
||||
```
|
||||
|
||||
- [ ] **Add `#[utoipa::path]` to `crates/presentation/src/handlers/health.rs`:**
|
||||
|
||||
```rust
|
||||
#[utoipa::path(
|
||||
get, path = "/health",
|
||||
responses((status = 200, description = "Service health status"))
|
||||
)]
|
||||
pub async fn health_handler(...) { ... }
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo check -p presentation` — Expected: no errors. Fix any utoipa annotation compile errors (missing imports, wrong types).
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add crates/presentation/src/handlers/
|
||||
git commit -m "feat(presentation): add utoipa path annotations to all handlers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: OpenAPI doc modules + serve /docs and /scalar
|
||||
|
||||
**Files:** `crates/presentation/src/openapi/` (all new), modify `routes.rs`, `lib.rs`
|
||||
|
||||
- [ ] **Create `crates/presentation/src/openapi/auth.rs`:**
|
||||
|
||||
```rust
|
||||
use utoipa::OpenApi;
|
||||
use api_types::{requests::{LoginRequest, RegisterRequest}, responses::{AuthResponse, ErrorResponse}};
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(crate::handlers::auth::post_register, crate::handlers::auth::post_login),
|
||||
components(schemas(RegisterRequest, LoginRequest, AuthResponse, ErrorResponse))
|
||||
)]
|
||||
pub struct AuthDoc;
|
||||
```
|
||||
|
||||
- [ ] **Create `crates/presentation/src/openapi/users.rs`:**
|
||||
|
||||
```rust
|
||||
use utoipa::OpenApi;
|
||||
use api_types::{requests::UpdateProfileRequest, responses::{UserResponse, ErrorResponse}};
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::handlers::users::get_me,
|
||||
crate::handlers::users::get_user,
|
||||
crate::handlers::users::patch_profile,
|
||||
),
|
||||
components(schemas(UserResponse, UpdateProfileRequest, ErrorResponse))
|
||||
)]
|
||||
pub struct UsersDoc;
|
||||
```
|
||||
|
||||
- [ ] **Create `crates/presentation/src/openapi/thoughts.rs`:**
|
||||
|
||||
```rust
|
||||
use utoipa::OpenApi;
|
||||
use api_types::{requests::{CreateThoughtRequest, EditThoughtRequest}, responses::ErrorResponse};
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::handlers::thoughts::post_thought,
|
||||
crate::handlers::thoughts::get_thought_handler,
|
||||
crate::handlers::thoughts::patch_thought,
|
||||
crate::handlers::thoughts::delete_thought_handler,
|
||||
crate::handlers::thoughts::get_thread_handler,
|
||||
),
|
||||
components(schemas(CreateThoughtRequest, EditThoughtRequest, ErrorResponse))
|
||||
)]
|
||||
pub struct ThoughtsDoc;
|
||||
```
|
||||
|
||||
- [ ] **Create `crates/presentation/src/openapi/feed.rs`:**
|
||||
|
||||
```rust
|
||||
use utoipa::OpenApi;
|
||||
use api_types::requests::{PaginationQuery, SearchQuery};
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::handlers::feed::home_feed,
|
||||
crate::handlers::feed::public_feed,
|
||||
crate::handlers::feed::search_handler,
|
||||
crate::handlers::feed::user_thoughts_handler,
|
||||
crate::handlers::feed::tag_thoughts_handler,
|
||||
),
|
||||
components(schemas(PaginationQuery, SearchQuery))
|
||||
)]
|
||||
pub struct FeedDoc;
|
||||
```
|
||||
|
||||
- [ ] **Create `crates/presentation/src/openapi/social.rs`:**
|
||||
|
||||
```rust
|
||||
use utoipa::OpenApi;
|
||||
use api_types::requests::SetTopFriendsRequest;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::handlers::social::post_like,
|
||||
crate::handlers::social::delete_like,
|
||||
crate::handlers::social::post_boost,
|
||||
crate::handlers::social::delete_boost,
|
||||
crate::handlers::social::post_follow,
|
||||
crate::handlers::social::delete_follow,
|
||||
crate::handlers::social::post_block,
|
||||
crate::handlers::social::delete_block,
|
||||
crate::handlers::social::put_top_friends,
|
||||
crate::handlers::social::get_top_friends_handler,
|
||||
),
|
||||
components(schemas(SetTopFriendsRequest))
|
||||
)]
|
||||
pub struct SocialDoc;
|
||||
```
|
||||
|
||||
- [ ] **Create `crates/presentation/src/openapi/notifications.rs`:**
|
||||
|
||||
```rust
|
||||
use utoipa::OpenApi;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(paths(
|
||||
crate::handlers::notifications::list_notifications,
|
||||
crate::handlers::notifications::mark_notification_read,
|
||||
crate::handlers::notifications::mark_all_read,
|
||||
))]
|
||||
pub struct NotificationsDoc;
|
||||
```
|
||||
|
||||
- [ ] **Create `crates/presentation/src/openapi/api_keys.rs`:**
|
||||
|
||||
```rust
|
||||
use utoipa::OpenApi;
|
||||
use api_types::{requests::CreateApiKeyRequest, responses::{ApiKeyResponse, CreatedApiKeyResponse}};
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
crate::handlers::api_keys::get_api_keys,
|
||||
crate::handlers::api_keys::post_api_key,
|
||||
crate::handlers::api_keys::delete_api_key_handler,
|
||||
),
|
||||
components(schemas(CreateApiKeyRequest, ApiKeyResponse, CreatedApiKeyResponse))
|
||||
)]
|
||||
pub struct ApiKeysDoc;
|
||||
```
|
||||
|
||||
- [ ] **Create `crates/presentation/src/openapi/health.rs`:**
|
||||
|
||||
```rust
|
||||
use utoipa::OpenApi;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(paths(crate::handlers::health::health_handler))]
|
||||
pub struct HealthDoc;
|
||||
```
|
||||
|
||||
- [ ] **Create `crates/presentation/src/openapi/mod.rs`:**
|
||||
|
||||
```rust
|
||||
mod api_keys;
|
||||
mod auth;
|
||||
mod feed;
|
||||
mod health;
|
||||
mod notifications;
|
||||
mod social;
|
||||
mod thoughts;
|
||||
mod users;
|
||||
|
||||
use axum::Router;
|
||||
use utoipa::{
|
||||
Modify, OpenApi,
|
||||
openapi::security::{ApiKey, ApiKeyValue, Http, HttpAuthScheme, SecurityScheme},
|
||||
};
|
||||
use utoipa_scalar::{Scalar, Servable};
|
||||
use utoipa_swagger_ui::SwaggerUi;
|
||||
|
||||
struct SecurityAddon;
|
||||
|
||||
impl Modify for SecurityAddon {
|
||||
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
||||
let components = openapi.components.get_or_insert_with(Default::default);
|
||||
components.add_security_scheme(
|
||||
"bearer_auth",
|
||||
SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)),
|
||||
);
|
||||
components.add_security_scheme(
|
||||
"api_key",
|
||||
SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("X-Api-Key"))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn build() -> utoipa::openapi::OpenApi {
|
||||
let mut api = auth::AuthDoc::openapi();
|
||||
api.info = utoipa::openapi::InfoBuilder::new()
|
||||
.title("Thoughts API")
|
||||
.version("2.0.0")
|
||||
.description(Some(
|
||||
"Federated social network API. Authenticate via `POST /auth/login` to get a Bearer token, \
|
||||
or use `X-Api-Key` header with a key from `POST /api-keys`."
|
||||
))
|
||||
.build();
|
||||
api.merge(users::UsersDoc::openapi());
|
||||
api.merge(thoughts::ThoughtsDoc::openapi());
|
||||
api.merge(feed::FeedDoc::openapi());
|
||||
api.merge(social::SocialDoc::openapi());
|
||||
api.merge(notifications::NotificationsDoc::openapi());
|
||||
api.merge(api_keys::ApiKeysDoc::openapi());
|
||||
api.merge(health::HealthDoc::openapi());
|
||||
SecurityAddon.modify(&mut api);
|
||||
api
|
||||
}
|
||||
|
||||
pub fn serve<S: Clone + Send + Sync + 'static>(router: Router<S>) -> Router<S> {
|
||||
tracing::info!("API docs at /docs (Swagger UI) and /scalar (Scalar)");
|
||||
let spec = build();
|
||||
router
|
||||
.merge(SwaggerUi::new("/docs").url("/openapi.json", spec.clone()))
|
||||
.merge(Scalar::with_url("/scalar", spec))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Add `pub mod openapi;`** to `crates/presentation/src/lib.rs`.
|
||||
|
||||
- [ ] **Call `openapi::serve` in `crates/presentation/src/routes.rs`** — update the final return in `router()`:
|
||||
|
||||
```rust
|
||||
pub fn router(fed_config: &ApFederationConfig) -> Router<AppState> {
|
||||
let api_routes = Router::new()
|
||||
// ... all existing routes unchanged ...
|
||||
;
|
||||
|
||||
let ap_routes = Router::new()
|
||||
// ... all existing AP routes unchanged ...
|
||||
;
|
||||
|
||||
let combined = Router::new()
|
||||
.merge(api_routes)
|
||||
.merge(ap_routes)
|
||||
.layer(FederationMiddleware::new(fed_config.0.clone()));
|
||||
|
||||
openapi::serve(combined)
|
||||
}
|
||||
```
|
||||
|
||||
Note: `openapi::serve` takes the combined router and merges the `/docs` and `/scalar` routes. Since it returns `Router<AppState>` and the swagger/scalar routes don't need state, this works cleanly.
|
||||
|
||||
- [ ] **Run:** `cargo build -p presentation` — Expected: clean build.
|
||||
|
||||
- [ ] **Smoke test:**
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres \
|
||||
JWT_SECRET=dev BASE_URL=http://localhost:3000 \
|
||||
cargo run -p presentation &
|
||||
sleep 3
|
||||
|
||||
# Verify OpenAPI JSON is valid
|
||||
curl -s http://localhost:3000/openapi.json | jq '.info.title'
|
||||
# Expected: "Thoughts API"
|
||||
|
||||
# Verify docs pages load
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/docs/
|
||||
# Expected: 200
|
||||
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/scalar
|
||||
# Expected: 200
|
||||
|
||||
kill %1
|
||||
```
|
||||
|
||||
- [ ] **Run full test suite:**
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace 2>&1 | tail -3
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add crates/presentation/src/openapi/ \
|
||||
crates/presentation/src/lib.rs \
|
||||
crates/presentation/src/routes.rs
|
||||
git commit -m "feat(presentation): OpenAPI docs at /docs (Swagger) and /scalar"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- ✅ All REST handlers annotated with `#[utoipa::path]` (Task 2)
|
||||
- ✅ All request DTOs get `ToSchema` or `IntoParams` (Task 1)
|
||||
- ✅ All response DTOs get `ToSchema` (Task 1)
|
||||
- ✅ `CreatedApiKeyResponse` added for the create-key endpoint (Task 1)
|
||||
- ✅ 8 feature-grouped doc structs assembled in `openapi/mod.rs` (Task 3)
|
||||
- ✅ Both Bearer token and X-Api-Key security schemes registered (Task 3)
|
||||
- ✅ `/docs` (Swagger UI) and `/scalar` served (Task 3)
|
||||
- ✅ `/openapi.json` served (Task 3)
|
||||
|
||||
**Placeholder scan:** None.
|
||||
|
||||
**Type consistency:**
|
||||
- `CreatedApiKeyResponse` defined in responses.rs (Task 1), referenced in `api_keys.rs` openapi module (Task 3) and annotated in handler (Task 2)
|
||||
- `PaginationQuery` and `SearchQuery` get `IntoParams` (not `ToSchema`) — correct for query params
|
||||
- `openapi::serve` takes `Router<S>` generic — works with `Router<AppState>` from routes.rs
|
||||
|
||||
**Notes:**
|
||||
- `utoipa-swagger-ui` with `"vendored"` feature bundles the Swagger UI static assets — no CDN dependency
|
||||
- Handlers returning `serde_json::Value` get response descriptions without body schemas — still useful for documenting status codes and security requirements
|
||||
- ActivityPub endpoints (inbox, outbox, webfinger, nodeinfo) are intentionally excluded — they serve AP JSON-LD, not REST JSON
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,917 +0,0 @@
|
||||
# Remote Actor Search & Follow Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let local users search for and follow ActivityPub users on other instances (e.g. `@user@mastodon.social`) from the existing search page.
|
||||
|
||||
**Architecture:** New `FederationActionPort` domain trait (lookup + follow), implemented by `ActivityPubService` in `activitypub-base`. Injected into `AppState` via bootstrap. Two new REST endpoints at `/federation/lookup` and `/federation/follow`. Frontend detects `@user@instance` handle format in the search bar and renders a `RemoteUserCard` with a Follow button.
|
||||
|
||||
**Tech Stack:** Rust (axum, sqlx, activitypub_federation crate), Next.js 15 (App Router, server components), TypeScript, Zod, shadcn/ui.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Action | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| Modify | `crates/domain/src/models/remote_actor.rs` | Add `avatar_url` field |
|
||||
| Modify | `crates/domain/src/errors.rs` | Add `ExternalService` variant |
|
||||
| Modify | `crates/domain/src/ports.rs` | Add `FederationActionPort` trait |
|
||||
| Modify | `crates/domain/src/testing.rs` | Impl `FederationActionPort` for `TestStore` |
|
||||
| Modify | `crates/adapters/activitypub-base/src/service.rs` | Impl `FederationActionPort` for `ActivityPubService` |
|
||||
| Modify | `crates/adapters/activitypub-base/src/lib.rs` | Re-export trait impl visibility |
|
||||
| Modify | `crates/presentation/src/state.rs` | Add `federation` field |
|
||||
| Modify | `crates/presentation/src/errors.rs` | Map `ExternalService` → 502 |
|
||||
| Modify | `crates/bootstrap/src/factory.rs` | Build `ActivityPubService`, wire `federation` |
|
||||
| Modify | `crates/bootstrap/src/main.rs` | Use `ap_service.federation_config()` for middleware |
|
||||
| Modify | `crates/api-types/src/responses.rs` | Add `RemoteActorResponse` |
|
||||
| Create | `crates/presentation/src/handlers/federation.rs` | `lookup` + `follow_remote` handlers |
|
||||
| Modify | `crates/presentation/src/handlers/mod.rs` | Expose `federation` module |
|
||||
| Modify | `crates/presentation/src/routes.rs` | Mount `/federation/*` routes |
|
||||
| Modify | `thoughts-frontend/lib/api.ts` | Add schema, `lookupRemoteActor`, `followRemoteUser` |
|
||||
| Modify | `thoughts-frontend/app/search/page.tsx` | Detect handle, call lookup, pass result |
|
||||
| Create | `thoughts-frontend/components/remote-user-card.tsx` | Shows remote actor + Follow button |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Domain model + port
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/domain/src/models/remote_actor.rs`
|
||||
- Modify: `crates/domain/src/errors.rs`
|
||||
- Modify: `crates/domain/src/ports.rs`
|
||||
- Modify: `crates/domain/src/testing.rs`
|
||||
|
||||
- [ ] **Step 1: Add `avatar_url` to `RemoteActor`**
|
||||
|
||||
In `crates/domain/src/models/remote_actor.rs`, add one field:
|
||||
|
||||
```rust
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RemoteActor {
|
||||
pub url: String,
|
||||
pub handle: String,
|
||||
pub display_name: Option<String>,
|
||||
pub inbox_url: String,
|
||||
pub shared_inbox_url: Option<String>,
|
||||
pub public_key: String,
|
||||
pub avatar_url: Option<String>, // ← add this
|
||||
pub last_fetched_at: DateTime<Utc>,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `ExternalService` to `DomainError`**
|
||||
|
||||
In `crates/domain/src/errors.rs`, add the variant:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Error, Clone)]
|
||||
pub enum DomainError {
|
||||
#[error("not found")]
|
||||
NotFound,
|
||||
#[error("unauthorized")]
|
||||
Unauthorized,
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
#[error("invalid input: {0}")]
|
||||
InvalidInput(String),
|
||||
#[error("external service error: {0}")]
|
||||
ExternalService(String), // ← add this
|
||||
#[error("internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `FederationActionPort` trait**
|
||||
|
||||
In `crates/domain/src/ports.rs`, after the `RemoteActorRepository` trait block, add:
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait FederationActionPort: Send + Sync {
|
||||
async fn lookup_actor(&self, handle: &str) -> Result<RemoteActor, DomainError>;
|
||||
async fn follow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError>;
|
||||
}
|
||||
```
|
||||
|
||||
Make sure `RemoteActor` is already imported — it's in the existing `use crate::models::remote_actor::RemoteActor;` import block.
|
||||
|
||||
- [ ] **Step 4: Write failing tests for the trait in `testing.rs`**
|
||||
|
||||
At the bottom of `crates/domain/src/testing.rs`, add:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod federation_port_tests {
|
||||
use super::*;
|
||||
use crate::value_objects::UserId;
|
||||
|
||||
fn uid() -> UserId {
|
||||
UserId::new()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_lookup_returns_not_found() {
|
||||
let store = TestStore::default();
|
||||
let err = store.lookup_actor("@alice@example.com").await.unwrap_err();
|
||||
assert!(matches!(err, DomainError::NotFound));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_follow_remote_is_noop_ok() {
|
||||
let store = TestStore::default();
|
||||
store.follow_remote(&uid(), "@alice@example.com").await.unwrap();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the tests to see them fail**
|
||||
|
||||
```bash
|
||||
cargo test -p domain -- federation_port_tests 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: compile error — `lookup_actor` and `follow_remote` not implemented on `TestStore`, and `FederationActionPort` trait not found.
|
||||
|
||||
- [ ] **Step 6: Implement `FederationActionPort` for `TestStore`**
|
||||
|
||||
In `crates/domain/src/testing.rs`, add after the existing `impl RemoteActorRepository for TestStore` block:
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
impl FederationActionPort for TestStore {
|
||||
async fn lookup_actor(&self, _handle: &str) -> Result<RemoteActor, DomainError> {
|
||||
Err(DomainError::NotFound)
|
||||
}
|
||||
|
||||
async fn follow_remote(&self, _local_user_id: &UserId, _handle: &str) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run tests to confirm they pass**
|
||||
|
||||
```bash
|
||||
cargo test -p domain -- federation_port_tests 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: `test federation_port_tests::test_store_lookup_returns_not_found ... ok` and `test_store_follow_remote_is_noop_ok ... ok`.
|
||||
|
||||
- [ ] **Step 8: Confirm the whole domain crate still compiles**
|
||||
|
||||
```bash
|
||||
cargo check -p domain 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/domain/src/models/remote_actor.rs \
|
||||
crates/domain/src/errors.rs \
|
||||
crates/domain/src/ports.rs \
|
||||
crates/domain/src/testing.rs
|
||||
git commit -m "feat(domain): FederationActionPort trait + avatar_url on RemoteActor"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `activitypub-base` — implement `FederationActionPort`
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/adapters/activitypub-base/src/service.rs`
|
||||
|
||||
- [ ] **Step 1: Write a compile-time impl check in `tests/service.rs`**
|
||||
|
||||
In `crates/adapters/activitypub-base/src/tests/service.rs`, add at the top:
|
||||
|
||||
```rust
|
||||
// Verify ActivityPubService satisfies the FederationActionPort contract at compile time.
|
||||
fn _assert_impl_federation_action_port()
|
||||
where
|
||||
crate::service::ActivityPubService: domain::ports::FederationActionPort,
|
||||
{
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to see compile failure**
|
||||
|
||||
```bash
|
||||
cargo check -p activitypub-base 2>&1 | tail -15
|
||||
```
|
||||
|
||||
Expected: error — `ActivityPubService` does not implement `FederationActionPort`.
|
||||
|
||||
- [ ] **Step 3: Implement `FederationActionPort` for `ActivityPubService`**
|
||||
|
||||
At the bottom of `crates/adapters/activitypub-base/src/service.rs`, before the closing of the file, add:
|
||||
|
||||
```rust
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::FederationActionPort for ActivityPubService {
|
||||
async fn lookup_actor(
|
||||
&self,
|
||||
handle: &str,
|
||||
) -> Result<domain::models::remote_actor::RemoteActor, domain::errors::DomainError> {
|
||||
use activitypub_federation::fetch::webfinger::webfinger_resolve_actor;
|
||||
let data = self.federation_config.to_request_data();
|
||||
let actor: crate::actors::DbActor = webfinger_resolve_actor(handle, &data)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?;
|
||||
Ok(domain::models::remote_actor::RemoteActor {
|
||||
url: actor.ap_id.to_string(),
|
||||
handle: actor.username.clone(),
|
||||
display_name: actor.bio.clone(),
|
||||
inbox_url: actor.inbox_url.to_string(),
|
||||
shared_inbox_url: None,
|
||||
public_key: actor.public_key_pem.clone(),
|
||||
avatar_url: actor.avatar_url.as_ref().map(|u| u.to_string()),
|
||||
last_fetched_at: actor.last_refreshed_at,
|
||||
})
|
||||
}
|
||||
|
||||
async fn follow_remote(
|
||||
&self,
|
||||
local_user_id: &domain::value_objects::UserId,
|
||||
handle: &str,
|
||||
) -> Result<(), domain::errors::DomainError> {
|
||||
self.follow(local_user_id.inner(), handle)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: `UserId::inner()` returns the underlying `uuid::Uuid`. Verify the method name with `grep -n "fn inner\|fn as_uuid\|fn into_uuid" crates/domain/src/value_objects.rs` — adjust if the method is named differently.
|
||||
|
||||
- [ ] **Step 4: Check `UserId` accessor method name**
|
||||
|
||||
```bash
|
||||
grep -n "fn inner\|fn as_uuid\|fn into_uuid\|pub fn " /mnt/drive/dev/thoughts/crates/domain/src/value_objects.rs | grep -i "userid\|UserId" | head -10
|
||||
```
|
||||
|
||||
If `inner()` doesn't exist, replace `local_user_id.inner()` with the correct method (e.g. `local_user_id.0`, `local_user_id.as_uuid()`, etc.).
|
||||
|
||||
- [ ] **Step 5: Compile to confirm the impl satisfies the trait**
|
||||
|
||||
```bash
|
||||
cargo check -p activitypub-base 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/adapters/activitypub-base/src/service.rs \
|
||||
crates/adapters/activitypub-base/src/tests/service.rs
|
||||
git commit -m "feat(activitypub-base): impl FederationActionPort for ActivityPubService"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Bootstrap — wire `ActivityPubService` into `AppState`
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/presentation/src/state.rs`
|
||||
- Modify: `crates/presentation/src/errors.rs`
|
||||
- Modify: `crates/bootstrap/src/factory.rs`
|
||||
- Modify: `crates/bootstrap/src/main.rs`
|
||||
|
||||
- [ ] **Step 1: Add `federation` to `AppState`**
|
||||
|
||||
In `crates/presentation/src/state.rs`, add the new field:
|
||||
|
||||
```rust
|
||||
use domain::ports::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub users: Arc<dyn UserRepository>,
|
||||
pub thoughts: Arc<dyn ThoughtRepository>,
|
||||
pub likes: Arc<dyn LikeRepository>,
|
||||
pub boosts: Arc<dyn BoostRepository>,
|
||||
pub follows: Arc<dyn FollowRepository>,
|
||||
pub blocks: Arc<dyn BlockRepository>,
|
||||
pub tags: Arc<dyn TagRepository>,
|
||||
pub api_keys: Arc<dyn ApiKeyRepository>,
|
||||
pub top_friends: Arc<dyn TopFriendRepository>,
|
||||
pub notifications: Arc<dyn NotificationRepository>,
|
||||
pub remote_actors: Arc<dyn RemoteActorRepository>,
|
||||
pub feed: Arc<dyn FeedRepository>,
|
||||
pub search: Arc<dyn SearchPort>,
|
||||
pub auth: Arc<dyn AuthService>,
|
||||
pub hasher: Arc<dyn PasswordHasher>,
|
||||
pub events: Arc<dyn EventPublisher>,
|
||||
pub federation: Arc<dyn FederationActionPort>, // ← add this
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Map `ExternalService` error in `presentation/src/errors.rs`**
|
||||
|
||||
Add the new match arm in `IntoResponse for ApiError`:
|
||||
|
||||
```rust
|
||||
Self::Domain(DomainError::ExternalService(_)) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"external service error".into(),
|
||||
),
|
||||
```
|
||||
|
||||
Place it before the `Self::Domain(DomainError::Internal(_))` arm.
|
||||
|
||||
- [ ] **Step 3: Refactor `factory.rs` to build `ActivityPubService`**
|
||||
|
||||
In `crates/bootstrap/src/factory.rs`, change the imports and the federation setup block.
|
||||
|
||||
Add import at top:
|
||||
```rust
|
||||
use activitypub_base::service::ActivityPubService;
|
||||
use domain::ports::FederationActionPort;
|
||||
```
|
||||
|
||||
Change `Infrastructure` struct:
|
||||
```rust
|
||||
pub struct Infrastructure {
|
||||
pub state: AppState,
|
||||
pub ap_service: Arc<ActivityPubService>,
|
||||
}
|
||||
```
|
||||
|
||||
Replace the current "3. ActivityPub federation" block (which builds `fed_data` + `fed_config`) with:
|
||||
|
||||
```rust
|
||||
// 3. ActivityPub federation
|
||||
let ap_service = Arc::new(
|
||||
ActivityPubService::new(
|
||||
Arc::new(PostgresFederationRepository::new(pool.clone())),
|
||||
Arc::new(PostgresApUserRepository::new(pool.clone(), cfg.base_url.clone())),
|
||||
Arc::new(ThoughtsObjectHandler::new(
|
||||
Arc::new(PgActivityPubRepository::new(pool.clone())),
|
||||
&cfg.base_url,
|
||||
)),
|
||||
cfg.base_url.clone(),
|
||||
cfg.allow_registration,
|
||||
"thoughts".to_string(),
|
||||
cfg.debug,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("Failed to build ActivityPubService"),
|
||||
);
|
||||
```
|
||||
|
||||
Remove the old `let fed_config = ...` line entirely.
|
||||
|
||||
In the `AppState { ... }` construction, add:
|
||||
```rust
|
||||
federation: ap_service.clone() as Arc<dyn FederationActionPort>,
|
||||
```
|
||||
|
||||
Change the `Infrastructure { ... }` return to:
|
||||
```rust
|
||||
Infrastructure { state, ap_service }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `main.rs` to use `ap_service`**
|
||||
|
||||
In `crates/bootstrap/src/main.rs`, change the middleware line from:
|
||||
|
||||
```rust
|
||||
.layer(infra.fed_config.middleware());
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```rust
|
||||
.layer(infra.ap_service.federation_config().middleware());
|
||||
```
|
||||
|
||||
Also update the AP router handlers — they use `actor_handler`, `inbox_handler`, etc. from `activitypub_base`. These don't change; only the middleware source changes.
|
||||
|
||||
- [ ] **Step 5: Confirm everything compiles**
|
||||
|
||||
```bash
|
||||
cargo check -p bootstrap 2>&1 | tail -15
|
||||
```
|
||||
|
||||
Expected: no errors. If `fed_config` is referenced elsewhere in `main.rs` or `factory.rs`, fix those references to use `ap_service.federation_config()`.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/presentation/src/state.rs \
|
||||
crates/presentation/src/errors.rs \
|
||||
crates/bootstrap/src/factory.rs \
|
||||
crates/bootstrap/src/main.rs
|
||||
git commit -m "feat(bootstrap): wire ActivityPubService as FederationActionPort in AppState"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: REST endpoints — lookup + follow
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/api-types/src/responses.rs`
|
||||
- Create: `crates/presentation/src/handlers/federation.rs`
|
||||
- Modify: `crates/presentation/src/handlers/mod.rs`
|
||||
- Modify: `crates/presentation/src/routes.rs`
|
||||
|
||||
- [ ] **Step 1: Add `RemoteActorResponse` to `api-types`**
|
||||
|
||||
In `crates/api-types/src/responses.rs`, add:
|
||||
|
||||
```rust
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteActorResponse {
|
||||
pub handle: String,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub url: String,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write failing handler tests**
|
||||
|
||||
Create `crates/presentation/src/handlers/federation.rs` with the test module first:
|
||||
|
||||
```rust
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use api_types::{requests::FollowRemoteRequest, responses::RemoteActorResponse};
|
||||
use domain::errors::DomainError;
|
||||
|
||||
use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
|
||||
|
||||
pub async fn lookup_handler(
|
||||
State(_s): State<AppState>,
|
||||
Query(_q): Query<LookupQuery>,
|
||||
) -> Result<Json<RemoteActorResponse>, ApiError> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
pub async fn follow_remote_handler(
|
||||
State(_s): State<AppState>,
|
||||
AuthUser(_uid): AuthUser,
|
||||
Json(_body): Json<FollowRemoteRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LookupQuery {
|
||||
pub handle: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, header},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use domain::testing::TestStore;
|
||||
use std::sync::Arc;
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn make_state() -> AppState {
|
||||
let store = Arc::new(TestStore::default());
|
||||
AppState {
|
||||
users: store.clone(),
|
||||
thoughts: store.clone(),
|
||||
likes: store.clone(),
|
||||
boosts: store.clone(),
|
||||
follows: store.clone(),
|
||||
blocks: store.clone(),
|
||||
tags: store.clone(),
|
||||
api_keys: store.clone(),
|
||||
top_friends: store.clone(),
|
||||
notifications: store.clone(),
|
||||
remote_actors: store.clone(),
|
||||
feed: store.clone(),
|
||||
search: store.clone(),
|
||||
auth: store.clone(),
|
||||
hasher: store.clone(),
|
||||
events: store.clone(),
|
||||
federation: store.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn app() -> Router {
|
||||
Router::new()
|
||||
.route("/federation/lookup", get(lookup_handler))
|
||||
.route("/federation/follow", post(follow_remote_handler))
|
||||
.with_state(make_state())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lookup_unknown_handle_returns_404() {
|
||||
let resp = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/federation/lookup?handle=%40alice%40example.com")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn follow_remote_without_auth_returns_401() {
|
||||
let resp = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/federation/follow")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"handle":"@alice@example.com"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: `TestStore` must implement `AuthService`, `PasswordHasher`, and `FederationActionPort` for `make_state()` to compile. Check `crates/domain/src/testing.rs` — if `TestStore` doesn't implement `AuthService` or `PasswordHasher`, use the existing pattern from other handler test setups in the codebase. You may need to construct `AppState` slightly differently (e.g. using a `NoOpAuth` stub). Check `crates/presentation/src/handlers/auth.rs` for any existing test patterns.
|
||||
|
||||
- [ ] **Step 3: Add `FollowRemoteRequest` to `api-types`**
|
||||
|
||||
In `crates/api-types/src/requests.rs`, add:
|
||||
|
||||
```rust
|
||||
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FollowRemoteRequest {
|
||||
pub handle: String,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to see them fail**
|
||||
|
||||
```bash
|
||||
cargo test -p presentation -- handlers::federation::tests 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: compile errors (handler bodies are `todo!()`) or panics. The goal is to confirm the tests exist and the wiring is right.
|
||||
|
||||
- [ ] **Step 5: Implement the handlers**
|
||||
|
||||
Replace the `todo!()` bodies in `federation.rs`:
|
||||
|
||||
```rust
|
||||
pub async fn lookup_handler(
|
||||
State(s): State<AppState>,
|
||||
Query(q): Query<LookupQuery>,
|
||||
) -> Result<Json<RemoteActorResponse>, ApiError> {
|
||||
let actor = s.federation.lookup_actor(&q.handle).await?;
|
||||
Ok(Json(RemoteActorResponse {
|
||||
handle: actor.handle,
|
||||
display_name: actor.display_name,
|
||||
avatar_url: actor.avatar_url,
|
||||
url: actor.url,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn follow_remote_handler(
|
||||
State(s): State<AppState>,
|
||||
AuthUser(uid): AuthUser,
|
||||
Json(body): Json<FollowRemoteRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
s.federation.follow_remote(&uid, &body.handle).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Expose the module**
|
||||
|
||||
In `crates/presentation/src/handlers/mod.rs`, add:
|
||||
|
||||
```rust
|
||||
pub mod federation;
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Mount routes**
|
||||
|
||||
In `crates/presentation/src/routes.rs`, add these two routes inside `let api_routes = Router::new()`:
|
||||
|
||||
```rust
|
||||
.route("/federation/lookup", get(federation::lookup_handler))
|
||||
.route("/federation/follow", post(federation::follow_remote_handler))
|
||||
```
|
||||
|
||||
Place them after the `/search` route for clarity.
|
||||
|
||||
- [ ] **Step 8: Run tests again to confirm they pass**
|
||||
|
||||
```bash
|
||||
cargo test -p presentation -- handlers::federation::tests 2>&1 | tail -15
|
||||
```
|
||||
|
||||
Expected:
|
||||
```
|
||||
test handlers::federation::tests::lookup_unknown_handle_returns_404 ... ok
|
||||
test handlers::federation::tests::follow_remote_without_auth_returns_401 ... ok
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Full compile check**
|
||||
|
||||
```bash
|
||||
cargo check 2>&1 | tail -15
|
||||
```
|
||||
|
||||
Expected: no errors.
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/api-types/src/responses.rs \
|
||||
crates/api-types/src/requests.rs \
|
||||
crates/presentation/src/handlers/federation.rs \
|
||||
crates/presentation/src/handlers/mod.rs \
|
||||
crates/presentation/src/routes.rs
|
||||
git commit -m "feat(presentation): /federation/lookup and /federation/follow endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Frontend — API client + search integration + RemoteUserCard
|
||||
|
||||
**Files:**
|
||||
- Modify: `thoughts-frontend/lib/api.ts`
|
||||
- Modify: `thoughts-frontend/app/search/page.tsx`
|
||||
- Create: `thoughts-frontend/components/remote-user-card.tsx`
|
||||
|
||||
- [ ] **Step 1: Add types and API functions to `lib/api.ts`**
|
||||
|
||||
After the `UserSchema` block (around line 15), add:
|
||||
|
||||
```typescript
|
||||
export const RemoteActorSchema = z.object({
|
||||
handle: z.string(),
|
||||
displayName: z.string().nullable(),
|
||||
avatarUrl: z.string().nullable(),
|
||||
url: z.string(),
|
||||
});
|
||||
export type RemoteActor = z.infer<typeof RemoteActorSchema>;
|
||||
```
|
||||
|
||||
After the existing `followUser` and `unfollowUser` functions, add:
|
||||
|
||||
```typescript
|
||||
export const lookupRemoteActor = (handle: string, token: string | null) =>
|
||||
apiFetch(
|
||||
`/federation/lookup?handle=${encodeURIComponent(handle)}`,
|
||||
{},
|
||||
RemoteActorSchema,
|
||||
token
|
||||
);
|
||||
|
||||
export const followRemoteUser = (handle: string, token: string) =>
|
||||
apiFetch(
|
||||
`/federation/follow`,
|
||||
{ method: "POST", body: JSON.stringify({ handle }) },
|
||||
z.null(),
|
||||
token
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `RemoteUserCard` component**
|
||||
|
||||
Create `thoughts-frontend/components/remote-user-card.tsx`:
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { followRemoteUser, RemoteActor } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UserAvatar } from "@/components/user-avatar";
|
||||
import { toast } from "sonner";
|
||||
import { UserPlus } from "lucide-react";
|
||||
|
||||
interface RemoteUserCardProps {
|
||||
actor: RemoteActor;
|
||||
}
|
||||
|
||||
export function RemoteUserCard({ actor }: RemoteUserCardProps) {
|
||||
const [followed, setFollowed] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { token } = useAuth();
|
||||
|
||||
const handleFollow = async () => {
|
||||
if (!token) {
|
||||
toast.error("You must be logged in to follow users.");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await followRemoteUser(actor.handle, token);
|
||||
setFollowed(true);
|
||||
toast.success(`Follow request sent to ${actor.handle}`);
|
||||
} catch {
|
||||
toast.error("Failed to send follow request.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<UserAvatar
|
||||
username={actor.handle}
|
||||
avatarUrl={actor.avatarUrl}
|
||||
size="md"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium">{actor.displayName ?? actor.handle}</p>
|
||||
<p className="text-sm text-muted-foreground">{actor.handle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleFollow}
|
||||
disabled={loading || followed}
|
||||
variant={followed ? "secondary" : "default"}
|
||||
size="sm"
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{followed ? "Requested" : "Follow"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Note: Check how `UserAvatar` is used in other components (e.g. `user-list-card.tsx`) to confirm the prop names match.
|
||||
|
||||
- [ ] **Step 3: Check `UserAvatar` props**
|
||||
|
||||
```bash
|
||||
grep -n "UserAvatar\|avatarUrl\|username" /mnt/drive/dev/thoughts/thoughts-frontend/components/user-avatar.tsx | head -10
|
||||
```
|
||||
|
||||
Adjust the `UserAvatar` usage in `RemoteUserCard` to match the actual props.
|
||||
|
||||
- [ ] **Step 4: Update `app/search/page.tsx` to detect handles and show remote result**
|
||||
|
||||
Replace the file with:
|
||||
|
||||
```typescript
|
||||
import { cookies } from "next/headers";
|
||||
import { getMe, search, lookupRemoteActor, User, RemoteActor } from "@/lib/api";
|
||||
import { UserListCard } from "@/components/user-list-card";
|
||||
import { RemoteUserCard } from "@/components/remote-user-card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ThoughtList } from "@/components/thought-list";
|
||||
|
||||
const HANDLE_RE = /^@[\w.-]+@[\w.-]+\.\w+$/;
|
||||
|
||||
interface SearchPageProps {
|
||||
searchParams: Promise<{ q?: string }>;
|
||||
}
|
||||
|
||||
export default async function SearchPage({ searchParams }: SearchPageProps) {
|
||||
const { q } = await searchParams;
|
||||
const query = q || "";
|
||||
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||||
|
||||
if (!query) {
|
||||
return (
|
||||
<div className="container mx-auto max-w-2xl p-4 sm:p-6 text-center">
|
||||
<h1 className="text-2xl font-bold mt-8">Search Thoughts</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Find users and thoughts across the platform.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isHandle = HANDLE_RE.test(query);
|
||||
|
||||
const [results, remoteActor, me] = await Promise.all([
|
||||
isHandle ? null : search(query, token).catch(() => null),
|
||||
isHandle ? lookupRemoteActor(query, token).catch(() => null) : null,
|
||||
token ? getMe(token).catch(() => null) : null,
|
||||
]);
|
||||
|
||||
const authorDetails = new Map<string, { avatarUrl?: string | null }>();
|
||||
if (results) {
|
||||
results.users.forEach((user: User) => {
|
||||
authorDetails.set(user.username, { avatarUrl: user.avatarUrl });
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
|
||||
<header className="my-6">
|
||||
<h1 className="text-3xl font-bold">Search Results</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Showing results for: "{query}"
|
||||
</p>
|
||||
</header>
|
||||
<main>
|
||||
{isHandle ? (
|
||||
remoteActor ? (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Remote user</h2>
|
||||
<RemoteUserCard actor={remoteActor} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground pt-8">
|
||||
No user found at {query}
|
||||
</p>
|
||||
)
|
||||
) : results ? (
|
||||
<Tabs defaultValue="thoughts" className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="thoughts">
|
||||
Thoughts ({results.thoughts.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="users">
|
||||
Users ({results.users.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="thoughts">
|
||||
<ThoughtList
|
||||
thoughts={results.thoughts}
|
||||
authorDetails={authorDetails}
|
||||
currentUser={me}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="users">
|
||||
<UserListCard users={results.users} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground pt-8">
|
||||
No results found or an error occurred.
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Type-check the frontend**
|
||||
|
||||
```bash
|
||||
cd /mnt/drive/dev/thoughts/thoughts-frontend && bun run tsc --noEmit 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: no errors. Fix any type mismatches before continuing.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
cd /mnt/drive/dev/thoughts/thoughts-frontend
|
||||
git add lib/api.ts app/search/page.tsx components/remote-user-card.tsx
|
||||
cd ..
|
||||
git commit -m "feat(frontend): remote actor lookup and follow from search page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage check:**
|
||||
- ✅ `FederationActionPort` trait with `lookup_actor` + `follow_remote` — Task 1
|
||||
- ✅ `avatar_url` on `RemoteActor` — Task 1
|
||||
- ✅ `ExternalService` error variant — Task 1
|
||||
- ✅ `ActivityPubService` impl — Task 2
|
||||
- ✅ Bootstrap refactor + `AppState.federation` — Task 3
|
||||
- ✅ `RemoteActorResponse` + `FollowRemoteRequest` — Task 4
|
||||
- ✅ `/federation/lookup` + `/federation/follow` endpoints — Task 4
|
||||
- ✅ Error mapping (ExternalService → 502) — Task 3
|
||||
- ✅ Frontend API client additions — Task 5
|
||||
- ✅ Handle detection regex in search page — Task 5
|
||||
- ✅ `RemoteUserCard` component — Task 5
|
||||
|
||||
**Placeholder check:** None found.
|
||||
|
||||
**Type consistency check:**
|
||||
- `RemoteActor.avatar_url: Option<String>` used in Task 1, mapped from `DbActor.avatar_url: Option<Url>` in Task 2 via `.map(|u| u.to_string())` ✅
|
||||
- `FollowRemoteRequest.handle` → `follow_remote(&uid, &body.handle)` ✅
|
||||
- `RemoteActorResponse` fields match `RemoteActor` domain model fields ✅
|
||||
- Frontend `RemoteActorSchema` camelCase fields match `#[serde(rename_all = "camelCase")]` on `RemoteActorResponse` ✅
|
||||
- `UserId::inner()` — verified as an assumption in Task 2 Step 4 with an explicit check step ✅
|
||||
@@ -1,246 +0,0 @@
|
||||
# v1 Parity Gaps Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Close four endpoints present in v1 but missing from v2: `GET /users/me`, `GET /users/{username}/thoughts`, `GET /tags/{name}`, and `GET /health`.
|
||||
|
||||
**Architecture:** All data layer work is already done — repositories, use cases, and response types exist. This plan is purely presentation layer additions: new handler functions in existing files, new routes registered in `routes.rs`. No domain or application changes needed.
|
||||
|
||||
**Tech Stack:** axum 0.8, existing AppState ports
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
```
|
||||
Modify: crates/presentation/src/handlers/users.rs ← add get_me handler
|
||||
Modify: crates/presentation/src/handlers/feed.rs ← add user_thoughts + tag_thoughts handlers
|
||||
Modify: crates/presentation/src/routes.rs ← register 4 new routes
|
||||
Create: crates/presentation/src/handlers/health.rs ← health check handler
|
||||
Modify: crates/presentation/src/handlers/mod.rs ← pub mod health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: GET /users/me, GET /users/{username}/thoughts, GET /tags/{name}
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/presentation/src/handlers/users.rs`
|
||||
- Modify: `crates/presentation/src/handlers/feed.rs`
|
||||
- Modify: `crates/presentation/src/routes.rs`
|
||||
|
||||
- [ ] **Add `get_me` handler** to `crates/presentation/src/handlers/users.rs` — append after `patch_profile`:
|
||||
|
||||
```rust
|
||||
pub async fn get_me(State(s): State<AppState>, AuthUser(uid): AuthUser) -> Result<Json<UserResponse>, ApiError> {
|
||||
let user = s.users.find_by_id(&uid).await?.ok_or(domain::errors::DomainError::NotFound)?;
|
||||
Ok(Json(to_user_response(&user)))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Add `user_thoughts_handler` and `tag_thoughts_handler`** to `crates/presentation/src/handlers/feed.rs` — append after `get_followers_handler`:
|
||||
|
||||
```rust
|
||||
pub async fn user_thoughts_handler(
|
||||
State(s): State<AppState>,
|
||||
Path(username): Path<String>,
|
||||
Query(q): Query<PaginationQuery>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
use application::use_cases::feed::get_user_feed;
|
||||
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?;
|
||||
Ok(Json(serde_json::json!({
|
||||
"total": result.total,
|
||||
"page": result.page,
|
||||
"per_page": result.per_page,
|
||||
"items": result.items.iter().map(|e| serde_json::json!({
|
||||
"id": e.thought.id.as_uuid(),
|
||||
"content": e.thought.content.as_str(),
|
||||
"visibility": e.thought.visibility.as_str(),
|
||||
"like_count": e.like_count,
|
||||
"boost_count": e.boost_count,
|
||||
"reply_count": e.reply_count,
|
||||
"created_at": e.thought.created_at,
|
||||
"updated_at": e.thought.updated_at,
|
||||
})).collect::<Vec<_>>()
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn tag_thoughts_handler(
|
||||
State(s): State<AppState>,
|
||||
Path(tag_name): Path<String>,
|
||||
Query(q): Query<PaginationQuery>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
use application::use_cases::feed::get_by_tag;
|
||||
let page = PageParams { page: q.page(), per_page: q.per_page() };
|
||||
let result = get_by_tag(&*s.tags, &tag_name, page).await?;
|
||||
Ok(Json(serde_json::json!({
|
||||
"tag": tag_name,
|
||||
"total": result.total,
|
||||
"page": result.page,
|
||||
"per_page": result.per_page,
|
||||
"items": result.items.iter().map(|t| serde_json::json!({
|
||||
"id": t.id.as_uuid(),
|
||||
"content": t.content.as_str(),
|
||||
"visibility": t.visibility.as_str(),
|
||||
"created_at": t.created_at,
|
||||
})).collect::<Vec<_>>()
|
||||
})))
|
||||
}
|
||||
```
|
||||
|
||||
Note: `get_user_by_username`, `PageParams`, `PaginationQuery` are already imported in `feed.rs`. Only `get_user_feed` and `get_by_tag` need adding to the `use application::use_cases::feed::` import line at the top. Check the existing import and extend it.
|
||||
|
||||
- [ ] **Register the three new routes** in `crates/presentation/src/routes.rs` — add to `api_routes`:
|
||||
|
||||
```rust
|
||||
// GET /users/me must be registered before /users/{username} to take precedence
|
||||
.route("/users/me", get(users::get_me).patch(users::patch_profile))
|
||||
.route("/users/{username}/thoughts", get(feed::user_thoughts_handler))
|
||||
.route("/tags/{name}", get(feed::tag_thoughts_handler))
|
||||
```
|
||||
|
||||
**Important:** The existing routes have `/users/me` only for PATCH. Replace that line:
|
||||
|
||||
Find:
|
||||
```rust
|
||||
.route("/users/me", patch(users::patch_profile))
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```rust
|
||||
.route("/users/me", get(users::get_me).patch(users::patch_profile))
|
||||
```
|
||||
|
||||
And add `/users/{username}/thoughts` and `/tags/{name}` anywhere in `api_routes`.
|
||||
|
||||
- [ ] **Run:** `cargo check -p presentation` — Expected: no errors.
|
||||
|
||||
- [ ] **Smoke test:**
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres JWT_SECRET=dev \
|
||||
BASE_URL=http://localhost:3000 cargo run -p presentation &
|
||||
sleep 2
|
||||
|
||||
TOKEN=$(curl -s -X POST http://localhost:3000/auth/register \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"username":"parity","email":"parity@test.com","password":"pw"}' | jq -r .token)
|
||||
|
||||
# GET /users/me
|
||||
curl -s http://localhost:3000/users/me -H "Authorization: Bearer $TOKEN" | jq .username
|
||||
|
||||
# POST a thought then fetch by tag (needs tag to exist)
|
||||
curl -s -X POST http://localhost:3000/thoughts \
|
||||
-H 'content-type: application/json' \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"content":"hello world"}' > /dev/null
|
||||
|
||||
# GET /users/{username}/thoughts
|
||||
curl -s "http://localhost:3000/users/parity/thoughts" | jq '.total'
|
||||
|
||||
# GET /tags/{name} (tag may be empty if no tagged thoughts)
|
||||
curl -s "http://localhost:3000/tags/welcome" | jq '.tag'
|
||||
|
||||
kill %1
|
||||
```
|
||||
|
||||
Expected: `username` = `"parity"`, `total` = 1, `tag` = `"welcome"`.
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add crates/presentation/src/handlers/users.rs \
|
||||
crates/presentation/src/handlers/feed.rs \
|
||||
crates/presentation/src/routes.rs
|
||||
git commit -m "feat(presentation): GET /users/me, GET /users/{username}/thoughts, GET /tags/{name}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: GET /health
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/presentation/src/handlers/health.rs`
|
||||
- Modify: `crates/presentation/src/handlers/mod.rs`
|
||||
- Modify: `crates/presentation/src/routes.rs`
|
||||
|
||||
- [ ] **Create `crates/presentation/src/handlers/health.rs`:**
|
||||
|
||||
```rust
|
||||
use axum::{extract::State, Json};
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn health_handler(State(s): State<AppState>) -> Json<serde_json::Value> {
|
||||
// Cheap liveness check: verify DB connectivity
|
||||
let db_ok = s.users.list_with_stats().await.is_ok();
|
||||
Json(serde_json::json!({
|
||||
"status": if db_ok { "ok" } else { "degraded" },
|
||||
"db": if db_ok { "connected" } else { "error" },
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Add `pub mod health;`** to `crates/presentation/src/handlers/mod.rs`.
|
||||
|
||||
- [ ] **Register the route** in `crates/presentation/src/routes.rs` — add to `api_routes`:
|
||||
|
||||
```rust
|
||||
.route("/health", get(health::health_handler))
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo check -p presentation` — Expected: no errors.
|
||||
|
||||
- [ ] **Run full test suite:**
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace 2>&1 | tail -3
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Smoke test:**
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres JWT_SECRET=dev \
|
||||
BASE_URL=http://localhost:3000 cargo run -p presentation &
|
||||
sleep 2
|
||||
curl -s http://localhost:3000/health | jq .
|
||||
kill %1
|
||||
```
|
||||
|
||||
Expected: `{"status":"ok","db":"connected"}`.
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add crates/presentation/src/handlers/health.rs \
|
||||
crates/presentation/src/handlers/mod.rs \
|
||||
crates/presentation/src/routes.rs
|
||||
git commit -m "feat(presentation): GET /health endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- ✅ `GET /users/me` — returns authenticated user's profile (Task 1)
|
||||
- ✅ `GET /users/{username}/thoughts` — paginated thought list for any user (Task 1)
|
||||
- ✅ `GET /tags/{name}` — paginated thoughts by tag name (Task 1)
|
||||
- ✅ `GET /health` — DB connectivity check returning JSON status (Task 2)
|
||||
|
||||
**Placeholder scan:** None.
|
||||
|
||||
**Type consistency:**
|
||||
- `get_me` returns `Json<UserResponse>` — same type as `get_user`, consistent
|
||||
- `user_thoughts_handler` calls `get_user_feed(&*s.thoughts, ...)` — matches use case signature in `feed.rs`
|
||||
- `tag_thoughts_handler` calls `get_by_tag(&*s.tags, ...)` — matches use case signature
|
||||
- `health_handler` calls `s.users.list_with_stats()` — exists on `UserRepository` port
|
||||
|
||||
**Notes:**
|
||||
- `/users/me` with GET + PATCH on the same route object — axum handles this with `.get(...).patch(...)`
|
||||
- Static `/users/me` takes precedence over `/users/{username}` in axum route matching, so no conflict even though both patterns exist
|
||||
- `list_with_stats()` does a DB query; acceptable for a health check — returns quickly and confirms DB connectivity
|
||||
- `/tags/{name}` matches `{name}` not `{tagName}` — consistent with Rust naming convention
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,707 +0,0 @@
|
||||
# Thoughts v2 — Plan 2: Full-Text Search (postgres-search)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Upgrade search from a full-table-scan ILIKE to indexed trigram search (pg_trgm), returning both thoughts and users from a single `/search` endpoint.
|
||||
|
||||
**Architecture:** A new `SearchPort` trait in domain defines cross-entity search (thoughts + users). `crates/adapters/postgres-search` implements it using `pg_trgm` similarity with GIN indexes. The existing `FeedRepository::search` in `postgres/feed.rs` is also upgraded to use the `%` trigram operator so it benefits from the new index. Presentation adds `search: Arc<dyn SearchPort>` to `AppState`.
|
||||
|
||||
**Tech Stack:** Rust, sqlx 0.8, PostgreSQL `pg_trgm` extension, GIN indexes, axum
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
```
|
||||
Modified: crates/domain/src/ports.rs ← add SearchPort trait
|
||||
Modified: crates/domain/src/testing.rs ← add TestStore impl for SearchPort
|
||||
Modified: crates/adapters/postgres-search/Cargo.toml ← add deps
|
||||
Modified: crates/adapters/postgres-search/src/lib.rs ← PgSearchRepository (was empty stub)
|
||||
Create: crates/adapters/postgres/migrations/004_search_indexes.sql
|
||||
Modified: crates/adapters/postgres/src/feed.rs ← upgrade ILIKE → trigram operator
|
||||
Modified: crates/presentation/src/state.rs ← add search field
|
||||
Modified: crates/presentation/src/lib.rs ← wire PgSearchRepository in build_state
|
||||
Modified: crates/presentation/src/handlers/feed.rs ← search_handler returns thoughts + users
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Migration — pg_trgm extension and GIN indexes
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/adapters/postgres/migrations/004_search_indexes.sql`
|
||||
|
||||
- [ ] **Write `004_search_indexes.sql`:**
|
||||
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_thoughts_content_trgm
|
||||
ON thoughts USING GIN(content gin_trgm_ops);
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_username_trgm
|
||||
ON users USING GIN(username gin_trgm_ops);
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_display_name_trgm
|
||||
ON users USING GIN(display_name gin_trgm_ops)
|
||||
WHERE display_name IS NOT NULL;
|
||||
```
|
||||
|
||||
- [ ] **Apply migration to test DB:**
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres \
|
||||
cargo sqlx migrate run --source crates/adapters/postgres/migrations
|
||||
```
|
||||
|
||||
Expected: `Applied 1/migrate search indexes`
|
||||
|
||||
- [ ] **Verify pg_trgm works:**
|
||||
|
||||
```bash
|
||||
psql postgres://postgres:postgres@localhost:5434/postgres \
|
||||
-c "SELECT similarity('hello world', 'hello');"
|
||||
```
|
||||
|
||||
Expected: a float value like `0.5` (not an error).
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add crates/adapters/postgres/migrations/004_search_indexes.sql
|
||||
git commit -m "feat(postgres): pg_trgm extension and GIN search indexes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Domain — SearchPort trait and TestStore implementation
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/domain/src/ports.rs`
|
||||
- Modify: `crates/domain/src/testing.rs`
|
||||
|
||||
- [ ] **Write failing test** — add to bottom of `crates/domain/src/testing.rs` (inside `#[cfg(any(test, feature = "test-helpers"))]`):
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod search_tests {
|
||||
use super::*;
|
||||
use crate::models::feed::PageParams;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_search_thoughts_returns_empty() {
|
||||
let store = TestStore::default();
|
||||
let result = store.search_thoughts("hello", &PageParams { page: 1, per_page: 20 }, None).await.unwrap();
|
||||
assert_eq!(result.total, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_store_search_users_returns_empty() {
|
||||
let store = TestStore::default();
|
||||
let result = store.search_users("alice", &PageParams { page: 1, per_page: 20 }).await.unwrap();
|
||||
assert_eq!(result.total, 0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo test -p domain` — Expected: FAIL (SearchPort not defined yet).
|
||||
|
||||
- [ ] **Add `SearchPort` to `crates/domain/src/ports.rs`** — append after the `FeedRepository` trait:
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait SearchPort: Send + Sync {
|
||||
/// Full-text search over public thoughts, ranked by trigram similarity.
|
||||
async fn search_thoughts(
|
||||
&self,
|
||||
query: &str,
|
||||
page: &PageParams,
|
||||
viewer_id: Option<&UserId>,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError>;
|
||||
|
||||
/// Search users by username or display_name, ranked by trigram similarity.
|
||||
async fn search_users(
|
||||
&self,
|
||||
query: &str,
|
||||
page: &PageParams,
|
||||
) -> Result<Paginated<User>, DomainError>;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Add `TestStore impl SearchPort`** in `crates/domain/src/testing.rs` — append after the `impl FeedRepository for TestStore` block:
|
||||
|
||||
```rust
|
||||
#[async_trait] impl SearchPort for TestStore {
|
||||
async fn search_thoughts(&self, _q: &str, _p: &PageParams, _v: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 })
|
||||
}
|
||||
async fn search_users(&self, _q: &str, _p: &PageParams) -> Result<Paginated<User>, DomainError> {
|
||||
Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo test -p domain` — Expected: all tests PASS.
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add crates/domain/src/ports.rs crates/domain/src/testing.rs
|
||||
git commit -m "feat(domain): SearchPort trait with thought and user search"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: postgres-search — PgSearchRepository
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/adapters/postgres-search/Cargo.toml`
|
||||
- Modify: `crates/adapters/postgres-search/src/lib.rs`
|
||||
|
||||
- [ ] **Write failing tests** at bottom of `crates/adapters/postgres-search/src/lib.rs`:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::{
|
||||
models::{thought::{Thought, Visibility}, user::User},
|
||||
ports::{SearchPort, ThoughtRepository, UserRepository},
|
||||
value_objects::*,
|
||||
};
|
||||
|
||||
async fn seed_thought(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) {
|
||||
use postgres::{thought::PgThoughtRepository, user::PgUserRepository};
|
||||
let urepo = PgUserRepository::new(pool.clone());
|
||||
let trepo = PgThoughtRepository::new(pool.clone());
|
||||
let u = User::new_local(
|
||||
UserId::new(),
|
||||
Username::new(username).unwrap(),
|
||||
Email::new(format!("{username}@ex.com")).unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
urepo.save(&u).await.unwrap();
|
||||
let t = Thought::new_local(
|
||||
ThoughtId::new(), u.id.clone(),
|
||||
Content::new_local(content).unwrap(),
|
||||
None, Visibility::Public, None, false,
|
||||
);
|
||||
trepo.save(&t).await.unwrap();
|
||||
(u, t)
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../postgres/migrations")]
|
||||
async fn search_thoughts_finds_by_keyword(pool: sqlx::PgPool) {
|
||||
seed_thought(&pool, "alice", "hello world").await;
|
||||
seed_thought(&pool, "bob", "goodbye universe").await;
|
||||
let repo = PgSearchRepository::new(pool);
|
||||
let result = repo.search_thoughts("hello", &domain::models::feed::PageParams { page: 1, per_page: 20 }, None).await.unwrap();
|
||||
assert_eq!(result.total, 1);
|
||||
assert_eq!(result.items[0].thought.content.as_str(), "hello world");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../postgres/migrations")]
|
||||
async fn search_users_finds_by_username(pool: sqlx::PgPool) {
|
||||
use postgres::user::PgUserRepository;
|
||||
let urepo = PgUserRepository::new(pool.clone());
|
||||
let alice = User::new_local(UserId::new(), Username::new("alice_search").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into()));
|
||||
urepo.save(&alice).await.unwrap();
|
||||
let repo = PgSearchRepository::new(pool);
|
||||
let result = repo.search_users("alice", &domain::models::feed::PageParams { page: 1, per_page: 20 }).await.unwrap();
|
||||
assert!(!result.items.is_empty());
|
||||
assert!(result.items.iter().any(|u| u.username.as_str() == "alice_search"));
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../postgres/migrations")]
|
||||
async fn search_thoughts_returns_empty_for_no_match(pool: sqlx::PgPool) {
|
||||
seed_thought(&pool, "alice", "hello world").await;
|
||||
let repo = PgSearchRepository::new(pool);
|
||||
let result = repo.search_thoughts("zzzzzzzzz", &domain::models::feed::PageParams { page: 1, per_page: 20 }, None).await.unwrap();
|
||||
assert_eq!(result.total, 0);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo test -p postgres-search` — Expected: FAIL (PgSearchRepository not defined).
|
||||
|
||||
- [ ] **Update `crates/adapters/postgres-search/Cargo.toml`:**
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "postgres-search"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
sqlx = { workspace = true, features = ["migrate"] }
|
||||
postgres = { workspace = true }
|
||||
```
|
||||
|
||||
Note: `postgres` in dev-dependencies is the internal crate at `crates/adapters/postgres` (already in workspace.dependencies). Add it to workspace.dependencies in root `Cargo.toml` if not already there:
|
||||
|
||||
```toml
|
||||
# In root Cargo.toml [workspace.dependencies] — verify this line exists:
|
||||
postgres = { path = "crates/adapters/postgres" }
|
||||
```
|
||||
|
||||
- [ ] **Write `crates/adapters/postgres-search/src/lib.rs`:**
|
||||
|
||||
```rust
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
feed::{FeedEntry, PageParams, Paginated},
|
||||
thought::Thought,
|
||||
user::User,
|
||||
},
|
||||
ports::SearchPort,
|
||||
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
|
||||
};
|
||||
use domain::models::thought::Visibility;
|
||||
|
||||
pub struct PgSearchRepository { pool: PgPool }
|
||||
impl PgSearchRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||
|
||||
// ── Feed row ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct FeedRow {
|
||||
thought_id: uuid::Uuid,
|
||||
t_user_id: uuid::Uuid,
|
||||
content: String,
|
||||
in_reply_to_id: Option<uuid::Uuid>,
|
||||
in_reply_to_url: Option<String>,
|
||||
t_ap_id: Option<String>,
|
||||
visibility: String,
|
||||
content_warning: Option<String>,
|
||||
sensitive: bool,
|
||||
t_local: bool,
|
||||
thought_created_at: DateTime<Utc>,
|
||||
updated_at: Option<DateTime<Utc>>,
|
||||
author_id: uuid::Uuid,
|
||||
username: String,
|
||||
email: String,
|
||||
password_hash: String,
|
||||
display_name: Option<String>,
|
||||
bio: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
header_url: Option<String>,
|
||||
custom_css: Option<String>,
|
||||
author_local: bool,
|
||||
u_ap_id: Option<String>,
|
||||
inbox_url: Option<String>,
|
||||
public_key: Option<String>,
|
||||
private_key: Option<String>,
|
||||
author_created_at: DateTime<Utc>,
|
||||
author_updated_at: DateTime<Utc>,
|
||||
like_count: i64,
|
||||
boost_count: i64,
|
||||
reply_count: i64,
|
||||
}
|
||||
|
||||
const FEED_SELECT: &str = "
|
||||
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,
|
||||
t.visibility, t.content_warning, t.sensitive, t.local AS t_local,
|
||||
t.created_at AS thought_created_at, t.updated_at,
|
||||
u.id AS author_id, u.username, u.email, u.password_hash,
|
||||
u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css,
|
||||
u.local AS author_local, u.ap_id AS u_ap_id, u.inbox_url,
|
||||
u.public_key, u.private_key,
|
||||
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";
|
||||
|
||||
fn row_to_entry(r: FeedRow) -> FeedEntry {
|
||||
let thought = Thought {
|
||||
id: ThoughtId::from_uuid(r.thought_id),
|
||||
user_id: UserId::from_uuid(r.t_user_id),
|
||||
content: Content::new_remote(r.content),
|
||||
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
|
||||
in_reply_to_url: r.in_reply_to_url,
|
||||
ap_id: r.t_ap_id,
|
||||
visibility: Visibility::from_str(&r.visibility),
|
||||
content_warning: r.content_warning,
|
||||
sensitive: r.sensitive,
|
||||
local: r.t_local,
|
||||
created_at: r.thought_created_at,
|
||||
updated_at: r.updated_at,
|
||||
};
|
||||
let author = User {
|
||||
id: UserId::from_uuid(r.author_id),
|
||||
username: Username::from_trusted(r.username),
|
||||
email: Email::from_trusted(r.email),
|
||||
password_hash: PasswordHash(r.password_hash),
|
||||
display_name: r.display_name, bio: r.bio,
|
||||
avatar_url: r.avatar_url, header_url: r.header_url, custom_css: r.custom_css,
|
||||
local: r.author_local, ap_id: r.u_ap_id, inbox_url: r.inbox_url,
|
||||
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 }
|
||||
}
|
||||
|
||||
// ── User row ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct UserRow {
|
||||
id: uuid::Uuid,
|
||||
username: String,
|
||||
email: String,
|
||||
password_hash: String,
|
||||
display_name: Option<String>,
|
||||
bio: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
header_url: Option<String>,
|
||||
custom_css: Option<String>,
|
||||
local: bool,
|
||||
ap_id: Option<String>,
|
||||
inbox_url: Option<String>,
|
||||
public_key: Option<String>,
|
||||
private_key: Option<String>,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<UserRow> for User {
|
||||
fn from(r: UserRow) -> Self {
|
||||
User {
|
||||
id: UserId::from_uuid(r.id),
|
||||
username: Username::from_trusted(r.username),
|
||||
email: Email::from_trusted(r.email),
|
||||
password_hash: PasswordHash(r.password_hash),
|
||||
display_name: r.display_name, bio: r.bio,
|
||||
avatar_url: r.avatar_url, header_url: r.header_url, custom_css: r.custom_css,
|
||||
local: r.local, ap_id: r.ap_id, inbox_url: r.inbox_url,
|
||||
public_key: r.public_key, private_key: r.private_key,
|
||||
created_at: r.created_at, updated_at: r.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const USER_SELECT: &str =
|
||||
"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";
|
||||
|
||||
// ── SearchPort implementation ─────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl SearchPort for PgSearchRepository {
|
||||
async fn search_thoughts(
|
||||
&self,
|
||||
query: &str,
|
||||
page: &PageParams,
|
||||
_viewer_id: Option<&UserId>,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
// Use pg_trgm similarity operator — requires the GIN index from migration 004
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM thoughts t
|
||||
WHERE t.content % $1 AND t.visibility='public'"
|
||||
)
|
||||
.bind(query)
|
||||
.fetch_one(&self.pool)
|
||||
.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 rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||
.bind(query)
|
||||
.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,
|
||||
})
|
||||
}
|
||||
|
||||
async fn search_users(
|
||||
&self,
|
||||
query: &str,
|
||||
page: &PageParams,
|
||||
) -> Result<Paginated<User>, DomainError> {
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM users u
|
||||
WHERE u.local=true AND (u.username % $1 OR u.display_name % $1)"
|
||||
)
|
||||
.bind(query)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
let sql = format!(
|
||||
"{USER_SELECT}
|
||||
WHERE local=true AND (username % $1 OR display_name % $1)
|
||||
ORDER BY similarity(username || ' ' || COALESCE(display_name,''), $1) DESC
|
||||
LIMIT $2 OFFSET $3"
|
||||
);
|
||||
let rows = sqlx::query_as::<_, UserRow>(&sql)
|
||||
.bind(query)
|
||||
.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(User::from).collect(),
|
||||
total,
|
||||
page: page.page,
|
||||
per_page: page.per_page,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Run:** `DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test -p postgres-search`
|
||||
Expected: 3 tests pass.
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add crates/adapters/postgres-search/
|
||||
git commit -m "feat(postgres-search): PgSearchRepository using pg_trgm"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Upgrade postgres ILIKE search to trigram operator
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/adapters/postgres/src/feed.rs`
|
||||
|
||||
The current `FeedRepository::search` uses `ILIKE '%pattern%'` which does a full table scan. Upgrade it to use the `%` trigram similarity operator which uses the GIN index from migration 004.
|
||||
|
||||
- [ ] **Update the `search` method** in `crates/adapters/postgres/src/feed.rs`:
|
||||
|
||||
Replace the entire `search` method (lines ~123-136) with:
|
||||
|
||||
```rust
|
||||
async fn search(&self, query: &str, page: &PageParams, _viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'"
|
||||
)
|
||||
.bind(query)
|
||||
.fetch_one(&self.pool)
|
||||
.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 rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||
.bind(query)
|
||||
.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 })
|
||||
}
|
||||
```
|
||||
|
||||
Also update the existing search test in `feed.rs` — the ILIKE test uses `"hello world"` vs `"hello"`. Trigram similarity works on substrings but with a minimum threshold. Update the test:
|
||||
|
||||
```rust
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn search_returns_matching_thoughts(pool: sqlx::PgPool) {
|
||||
let (_, _) = seed(&pool, "alice", "hello world").await;
|
||||
let (_, _) = seed(&pool, "bob", "goodbye world").await;
|
||||
let repo = PgFeedRepository::new(pool);
|
||||
// pg_trgm matches "hello" in "hello world" via trigram similarity
|
||||
let result = repo.search("hello world", &PageParams { page: 1, per_page: 20 }, None).await.unwrap();
|
||||
assert!(result.total >= 1);
|
||||
assert!(result.items.iter().any(|e| e.thought.content.as_str() == "hello world"));
|
||||
}
|
||||
```
|
||||
|
||||
Note: use the full string `"hello world"` as query since single short words may fall below the default similarity threshold (0.3). Alternatively, adjust the threshold — but keeping the test realistic is better.
|
||||
|
||||
- [ ] **Run:** `DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test -p postgres`
|
||||
Expected: all tests pass.
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add crates/adapters/postgres/src/feed.rs
|
||||
git commit -m "feat(postgres): upgrade search from ILIKE to pg_trgm similarity"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Wire SearchPort into presentation
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/presentation/src/state.rs`
|
||||
- Modify: `crates/presentation/src/lib.rs`
|
||||
- Modify: `crates/presentation/src/handlers/feed.rs`
|
||||
|
||||
- [ ] **Add `search` field to `AppState`** in `crates/presentation/src/state.rs`:
|
||||
|
||||
```rust
|
||||
use std::sync::Arc;
|
||||
use domain::ports::*;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub users: Arc<dyn UserRepository>,
|
||||
pub thoughts: Arc<dyn ThoughtRepository>,
|
||||
pub likes: Arc<dyn LikeRepository>,
|
||||
pub boosts: Arc<dyn BoostRepository>,
|
||||
pub follows: Arc<dyn FollowRepository>,
|
||||
pub blocks: Arc<dyn BlockRepository>,
|
||||
pub tags: Arc<dyn TagRepository>,
|
||||
pub api_keys: Arc<dyn ApiKeyRepository>,
|
||||
pub top_friends: Arc<dyn TopFriendRepository>,
|
||||
pub notifications: Arc<dyn NotificationRepository>,
|
||||
pub remote_actors: Arc<dyn RemoteActorRepository>,
|
||||
pub feed: Arc<dyn FeedRepository>,
|
||||
pub search: Arc<dyn SearchPort>, // NEW
|
||||
pub auth: Arc<dyn AuthService>,
|
||||
pub hasher: Arc<dyn PasswordHasher>,
|
||||
pub events: Arc<dyn EventPublisher>,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Wire `PgSearchRepository` in `build_state`** in `crates/presentation/src/lib.rs`:
|
||||
|
||||
Add `postgres_search` import and the field. The lib.rs `build_state` function currently returns `AppState { ... }` — add one line for `search`:
|
||||
|
||||
```rust
|
||||
// At top of file, add:
|
||||
use postgres_search::PgSearchRepository;
|
||||
|
||||
// In build_state, add to the AppState struct literal:
|
||||
search: Arc::new(PgSearchRepository::new(pool.clone())),
|
||||
```
|
||||
|
||||
Also add `postgres-search` to `crates/presentation/Cargo.toml`:
|
||||
|
||||
```toml
|
||||
postgres-search = { workspace = true }
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo check -p presentation` — Expected: no errors.
|
||||
|
||||
- [ ] **Update `search_handler`** in `crates/presentation/src/handlers/feed.rs` to use `SearchPort` and return both thoughts and users:
|
||||
|
||||
Replace the existing `search_handler` function:
|
||||
|
||||
```rust
|
||||
pub async fn search_handler(
|
||||
State(s): State<AppState>,
|
||||
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||
Query(q): Query<SearchQuery>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
use domain::models::feed::PageParams;
|
||||
let page = PageParams { page: q.page.unwrap_or(1), per_page: q.per_page.unwrap_or(20) };
|
||||
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),
|
||||
);
|
||||
|
||||
let thoughts = thoughts_result?.items.into_iter().map(|e| serde_json::json!({
|
||||
"id": e.thought.id.as_uuid(),
|
||||
"content": e.thought.content.as_str(),
|
||||
"author": to_user_response(&e.author),
|
||||
"like_count": e.like_count,
|
||||
"boost_count": e.boost_count,
|
||||
"reply_count": e.reply_count,
|
||||
"created_at": e.thought.created_at,
|
||||
})).collect::<Vec<_>>();
|
||||
|
||||
let users = users_result?.items.into_iter().map(|u| to_user_response(&u)).collect::<Vec<_>>();
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"query": query,
|
||||
"thoughts": thoughts,
|
||||
"users": users,
|
||||
})))
|
||||
}
|
||||
```
|
||||
|
||||
Add `use crate::handlers::auth::to_user_response;` at the top of `feed.rs` if not already imported.
|
||||
|
||||
- [ ] **Run:** `cargo build -p presentation` — Expected: clean build.
|
||||
|
||||
- [ ] **Smoke test:**
|
||||
|
||||
```bash
|
||||
# Start server
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres JWT_SECRET=dev cargo run -p presentation &
|
||||
sleep 2
|
||||
|
||||
# Register + post a thought + search
|
||||
TOKEN=$(curl -s -X POST http://localhost:3000/auth/register \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"username":"searcher","email":"searcher@test.com","password":"pw"}' | jq -r .token)
|
||||
|
||||
curl -s -X POST http://localhost:3000/thoughts \
|
||||
-H 'content-type: application/json' \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"content":"searching for trigrams"}'
|
||||
|
||||
curl -s "http://localhost:3000/search?q=trigram" | jq .
|
||||
|
||||
kill %1
|
||||
```
|
||||
|
||||
Expected: JSON with `thoughts` array containing the posted thought, `users` array.
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add crates/presentation/src/state.rs crates/presentation/src/lib.rs \
|
||||
crates/presentation/src/handlers/feed.rs crates/presentation/Cargo.toml
|
||||
git commit -m "feat(presentation): wire SearchPort, upgrade /search to return thoughts + users"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- ✅ pg_trgm extension + GIN indexes (Task 1)
|
||||
- ✅ `SearchPort` trait in domain (Task 2)
|
||||
- ✅ `postgres-search` crate filled in with `PgSearchRepository` (Task 3)
|
||||
- ✅ Existing ILIKE upgraded to trigram operator (Task 4)
|
||||
- ✅ Presentation wired: `search: Arc<dyn SearchPort>` in AppState (Task 5)
|
||||
- ✅ `/search` endpoint returns both thoughts and users (Task 5)
|
||||
|
||||
**Placeholder scan:** None — all code blocks are complete.
|
||||
|
||||
**Type consistency:**
|
||||
- `SearchPort::search_thoughts` → returns `Paginated<FeedEntry>` — matches domain model
|
||||
- `SearchPort::search_users` → returns `Paginated<User>` — matches domain model
|
||||
- `PgSearchRepository::new(pool: PgPool)` — consistent with all other repo constructors
|
||||
- `AppState.search: Arc<dyn SearchPort>` — consistent with existing fields
|
||||
|
||||
**Notes for implementer:**
|
||||
- `pg_trgm` `%` operator default threshold is 0.3 — short single-word queries may return no results if the word is too short. The smoke test uses `"trigram"` (7 chars) which is long enough.
|
||||
- `CONCURRENTLY` in migration lets the index build without locking the table — safe for production.
|
||||
- `postgres-search` dev-dependency on `postgres` crate is for seeding test data only — no runtime coupling.
|
||||
@@ -1,996 +0,0 @@
|
||||
# Thoughts v2 — Plan 3: Events + Worker (NATS)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Wire real async event processing — use cases publish domain events to NATS, a worker binary subscribes and runs handlers (NotificationHandler creates DB records; FederationHandler is stubbed for Plan 4).
|
||||
|
||||
**Architecture:** `event-payload/` holds the serializable NATS wire types. `nats/` wraps `async-nats` and implements both `EventPublisher` (publish to NATS) and `EventConsumer` (subscribe, yield `EventEnvelope` stream). `worker/` is a standalone binary that consumes events and dispatches to handlers. `presentation/` swaps its `NoOpEventPublisher` for the real NATS publisher. `event-publisher/` stays a stub (future fan-out to multiple backends).
|
||||
|
||||
**Tech Stack:** Rust, async-nats 0.38, serde_json, futures, async-stream, tokio
|
||||
|
||||
**Prerequisites:** NATS server running locally. Start with:
|
||||
```bash
|
||||
docker run -d --name nats -p 4222:4222 nats:latest
|
||||
# or add to docker-compose if preferred
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
```
|
||||
Modified: Cargo.toml ← add async-nats, async-stream to workspace.dependencies
|
||||
Modified: crates/adapters/event-payload/Cargo.toml ← add deps
|
||||
Modified: crates/adapters/event-payload/src/lib.rs ← EventPayload enum + subject() + From<&DomainEvent>
|
||||
Modified: crates/adapters/nats/Cargo.toml ← add deps
|
||||
Modified: crates/adapters/nats/src/lib.rs ← NatsEventPublisher + NatsEventConsumer
|
||||
Modified: crates/worker/Cargo.toml ← add deps, add [[bin]]
|
||||
Create: crates/worker/src/handlers.rs ← NotificationHandler, FederationHandler (stub)
|
||||
Modified: crates/worker/src/main.rs ← consumer loop binary
|
||||
Modified: crates/presentation/src/lib.rs ← swap NoOp for NatsEventPublisher
|
||||
Modified: crates/presentation/Cargo.toml ← add nats dep
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Workspace deps + event-payload crate
|
||||
|
||||
**Files:**
|
||||
- Modify: `Cargo.toml` (root workspace)
|
||||
- Modify: `crates/adapters/event-payload/Cargo.toml`
|
||||
- Modify: `crates/adapters/event-payload/src/lib.rs`
|
||||
|
||||
- [ ] **Add to root `Cargo.toml` `[workspace.dependencies]`:**
|
||||
|
||||
```toml
|
||||
async-nats = "0.38"
|
||||
async-stream = "0.3"
|
||||
|
||||
event-payload = { path = "crates/adapters/event-payload" }
|
||||
event-publisher = { path = "crates/adapters/event-publisher" }
|
||||
nats = { path = "crates/adapters/nats" }
|
||||
```
|
||||
|
||||
Check if `event-payload`, `event-publisher`, `nats` are already listed — they should be from Plan 1 scaffolding. If so, skip those lines and only add `async-nats` and `async-stream`.
|
||||
|
||||
- [ ] **Write `crates/adapters/event-payload/Cargo.toml`:**
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "event-payload"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
```
|
||||
|
||||
- [ ] **Write `crates/adapters/event-payload/src/lib.rs`:**
|
||||
|
||||
```rust
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Serializable mirror of domain::events::DomainEvent.
|
||||
/// All IDs are Strings (UUID hex) — no domain type dependencies.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", content = "data")]
|
||||
pub enum EventPayload {
|
||||
ThoughtCreated {
|
||||
thought_id: String,
|
||||
user_id: String,
|
||||
in_reply_to_id: Option<String>,
|
||||
},
|
||||
ThoughtDeleted {
|
||||
thought_id: String,
|
||||
user_id: String,
|
||||
},
|
||||
ThoughtUpdated {
|
||||
thought_id: String,
|
||||
user_id: String,
|
||||
},
|
||||
LikeAdded {
|
||||
like_id: String,
|
||||
user_id: String,
|
||||
thought_id: String,
|
||||
},
|
||||
LikeRemoved {
|
||||
user_id: String,
|
||||
thought_id: String,
|
||||
},
|
||||
BoostAdded {
|
||||
boost_id: String,
|
||||
user_id: String,
|
||||
thought_id: String,
|
||||
},
|
||||
BoostRemoved {
|
||||
user_id: String,
|
||||
thought_id: String,
|
||||
},
|
||||
FollowRequested {
|
||||
follower_id: String,
|
||||
following_id: String,
|
||||
},
|
||||
FollowAccepted {
|
||||
follower_id: String,
|
||||
following_id: String,
|
||||
},
|
||||
FollowRejected {
|
||||
follower_id: String,
|
||||
following_id: String,
|
||||
},
|
||||
Unfollowed {
|
||||
follower_id: String,
|
||||
following_id: String,
|
||||
},
|
||||
UserBlocked {
|
||||
blocker_id: String,
|
||||
blocked_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl EventPayload {
|
||||
/// Returns the NATS subject for this event.
|
||||
pub fn subject(&self) -> &'static str {
|
||||
match self {
|
||||
Self::ThoughtCreated { .. } => "thoughts.created",
|
||||
Self::ThoughtDeleted { .. } => "thoughts.deleted",
|
||||
Self::ThoughtUpdated { .. } => "thoughts.updated",
|
||||
Self::LikeAdded { .. } => "likes.added",
|
||||
Self::LikeRemoved { .. } => "likes.removed",
|
||||
Self::BoostAdded { .. } => "boosts.added",
|
||||
Self::BoostRemoved { .. } => "boosts.removed",
|
||||
Self::FollowRequested { .. } => "follows.requested",
|
||||
Self::FollowAccepted { .. } => "follows.accepted",
|
||||
Self::FollowRejected { .. } => "follows.rejected",
|
||||
Self::Unfollowed { .. } => "follows.removed",
|
||||
Self::UserBlocked { .. } => "users.blocked",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn thought_created_roundtrip() {
|
||||
let p = EventPayload::ThoughtCreated {
|
||||
thought_id: "abc".into(),
|
||||
user_id: "def".into(),
|
||||
in_reply_to_id: None,
|
||||
};
|
||||
let json = serde_json::to_string(&p).unwrap();
|
||||
let back: EventPayload = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.subject(), "thoughts.created");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_subjects_are_unique() {
|
||||
let samples: &[EventPayload] = &[
|
||||
EventPayload::ThoughtCreated { thought_id: "a".into(), user_id: "b".into(), in_reply_to_id: None },
|
||||
EventPayload::ThoughtDeleted { thought_id: "a".into(), user_id: "b".into() },
|
||||
EventPayload::ThoughtUpdated { thought_id: "a".into(), user_id: "b".into() },
|
||||
EventPayload::LikeAdded { like_id: "a".into(), user_id: "b".into(), thought_id: "c".into() },
|
||||
EventPayload::LikeRemoved { user_id: "b".into(), thought_id: "c".into() },
|
||||
EventPayload::BoostAdded { boost_id: "a".into(), user_id: "b".into(), thought_id: "c".into() },
|
||||
EventPayload::BoostRemoved { user_id: "b".into(), thought_id: "c".into() },
|
||||
EventPayload::FollowRequested { follower_id: "a".into(), following_id: "b".into() },
|
||||
EventPayload::FollowAccepted { follower_id: "a".into(), following_id: "b".into() },
|
||||
EventPayload::FollowRejected { follower_id: "a".into(), following_id: "b".into() },
|
||||
EventPayload::Unfollowed { follower_id: "a".into(), following_id: "b".into() },
|
||||
EventPayload::UserBlocked { blocker_id: "a".into(), blocked_id: "b".into() },
|
||||
];
|
||||
let mut subjects: Vec<&str> = samples.iter().map(|p| p.subject()).collect();
|
||||
subjects.sort();
|
||||
subjects.dedup();
|
||||
assert_eq!(subjects.len(), samples.len(), "each event must have a unique subject");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo test -p event-payload`
|
||||
Expected: 2 tests pass.
|
||||
|
||||
- [ ] **Commit:**
|
||||
```bash
|
||||
git add Cargo.toml crates/adapters/event-payload/
|
||||
git commit -m "feat(event-payload): serializable NATS event payload types"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: nats crate — NatsEventPublisher + NatsEventConsumer
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/adapters/nats/Cargo.toml`
|
||||
- Modify: `crates/adapters/nats/src/lib.rs`
|
||||
|
||||
- [ ] **Write `crates/adapters/nats/Cargo.toml`:**
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "nats"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
event-payload = { workspace = true }
|
||||
async-nats = { workspace = true }
|
||||
async-stream = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
```
|
||||
|
||||
- [ ] **Write test** at bottom of `crates/adapters/nats/src/lib.rs`:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::value_objects::{ThoughtId, UserId};
|
||||
|
||||
#[test]
|
||||
fn payload_from_domain_event_has_correct_subject() {
|
||||
let event = domain::events::DomainEvent::ThoughtCreated {
|
||||
thought_id: ThoughtId::new(),
|
||||
user_id: UserId::new(),
|
||||
in_reply_to_id: None,
|
||||
};
|
||||
let payload = EventPayload::from(&event);
|
||||
assert_eq!(payload.subject(), "thoughts.created");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_event_roundtrip_via_payload() {
|
||||
let uid = UserId::new();
|
||||
let tid = ThoughtId::new();
|
||||
let event = domain::events::DomainEvent::LikeAdded {
|
||||
like_id: domain::value_objects::LikeId::new(),
|
||||
user_id: uid.clone(),
|
||||
thought_id: tid.clone(),
|
||||
};
|
||||
let payload = EventPayload::from(&event);
|
||||
let back = domain::events::DomainEvent::try_from(payload).unwrap();
|
||||
if let domain::events::DomainEvent::LikeAdded { user_id, thought_id, .. } = back {
|
||||
assert_eq!(user_id, uid);
|
||||
assert_eq!(thought_id, tid);
|
||||
} else {
|
||||
panic!("wrong variant");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo test -p nats` — Expected: FAIL (lib.rs is empty).
|
||||
|
||||
- [ ] **Write `crates/adapters/nats/src/lib.rs`:**
|
||||
|
||||
```rust
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::{DomainEvent, EventEnvelope},
|
||||
ports::{EventConsumer, EventPublisher},
|
||||
value_objects::{BoostId, LikeId, ThoughtId, UserId},
|
||||
};
|
||||
use event_payload::EventPayload;
|
||||
use futures::stream::BoxStream;
|
||||
|
||||
// ── DomainEvent → EventPayload ─────────────────────────────────────────────
|
||||
|
||||
impl From<&DomainEvent> for EventPayload {
|
||||
fn from(e: &DomainEvent) -> Self {
|
||||
match e {
|
||||
DomainEvent::ThoughtCreated { thought_id, user_id, in_reply_to_id } => Self::ThoughtCreated {
|
||||
thought_id: thought_id.to_string(),
|
||||
user_id: user_id.to_string(),
|
||||
in_reply_to_id: in_reply_to_id.as_ref().map(|x| x.to_string()),
|
||||
},
|
||||
DomainEvent::ThoughtDeleted { thought_id, user_id } => Self::ThoughtDeleted {
|
||||
thought_id: thought_id.to_string(), user_id: user_id.to_string(),
|
||||
},
|
||||
DomainEvent::ThoughtUpdated { thought_id, user_id } => Self::ThoughtUpdated {
|
||||
thought_id: thought_id.to_string(), user_id: user_id.to_string(),
|
||||
},
|
||||
DomainEvent::LikeAdded { like_id, user_id, thought_id } => Self::LikeAdded {
|
||||
like_id: like_id.to_string(), user_id: user_id.to_string(), thought_id: thought_id.to_string(),
|
||||
},
|
||||
DomainEvent::LikeRemoved { user_id, thought_id } => Self::LikeRemoved {
|
||||
user_id: user_id.to_string(), thought_id: thought_id.to_string(),
|
||||
},
|
||||
DomainEvent::BoostAdded { boost_id, user_id, thought_id } => Self::BoostAdded {
|
||||
boost_id: boost_id.to_string(), user_id: user_id.to_string(), thought_id: thought_id.to_string(),
|
||||
},
|
||||
DomainEvent::BoostRemoved { user_id, thought_id } => Self::BoostRemoved {
|
||||
user_id: user_id.to_string(), thought_id: thought_id.to_string(),
|
||||
},
|
||||
DomainEvent::FollowRequested { follower_id, following_id } => Self::FollowRequested {
|
||||
follower_id: follower_id.to_string(), following_id: following_id.to_string(),
|
||||
},
|
||||
DomainEvent::FollowAccepted { follower_id, following_id } => Self::FollowAccepted {
|
||||
follower_id: follower_id.to_string(), following_id: following_id.to_string(),
|
||||
},
|
||||
DomainEvent::FollowRejected { follower_id, following_id } => Self::FollowRejected {
|
||||
follower_id: follower_id.to_string(), following_id: following_id.to_string(),
|
||||
},
|
||||
DomainEvent::Unfollowed { follower_id, following_id } => Self::Unfollowed {
|
||||
follower_id: follower_id.to_string(), following_id: following_id.to_string(),
|
||||
},
|
||||
DomainEvent::UserBlocked { blocker_id, blocked_id } => Self::UserBlocked {
|
||||
blocker_id: blocker_id.to_string(), blocked_id: blocked_id.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── EventPayload → DomainEvent ─────────────────────────────────────────────
|
||||
|
||||
fn parse_uuid(s: &str, field: &str) -> Result<uuid::Uuid, DomainError> {
|
||||
uuid::Uuid::parse_str(s)
|
||||
.map_err(|_| DomainError::Internal(format!("invalid uuid for {field}: {s}")))
|
||||
}
|
||||
|
||||
impl TryFrom<EventPayload> for DomainEvent {
|
||||
type Error = DomainError;
|
||||
|
||||
fn try_from(p: EventPayload) -> Result<Self, DomainError> {
|
||||
Ok(match p {
|
||||
EventPayload::ThoughtCreated { thought_id, user_id, in_reply_to_id } => DomainEvent::ThoughtCreated {
|
||||
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
in_reply_to_id: in_reply_to_id
|
||||
.map(|s| parse_uuid(&s, "in_reply_to_id").map(ThoughtId::from_uuid))
|
||||
.transpose()?,
|
||||
},
|
||||
EventPayload::ThoughtDeleted { thought_id, user_id } => DomainEvent::ThoughtDeleted {
|
||||
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
},
|
||||
EventPayload::ThoughtUpdated { thought_id, user_id } => DomainEvent::ThoughtUpdated {
|
||||
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
},
|
||||
EventPayload::LikeAdded { like_id, user_id, thought_id } => DomainEvent::LikeAdded {
|
||||
like_id: LikeId::from_uuid(parse_uuid(&like_id, "like_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||
},
|
||||
EventPayload::LikeRemoved { user_id, thought_id } => DomainEvent::LikeRemoved {
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||
},
|
||||
EventPayload::BoostAdded { boost_id, user_id, thought_id } => DomainEvent::BoostAdded {
|
||||
boost_id: BoostId::from_uuid(parse_uuid(&boost_id, "boost_id")?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||
},
|
||||
EventPayload::BoostRemoved { user_id, thought_id } => DomainEvent::BoostRemoved {
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
|
||||
},
|
||||
EventPayload::FollowRequested { follower_id, following_id } => DomainEvent::FollowRequested {
|
||||
follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
|
||||
following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?),
|
||||
},
|
||||
EventPayload::FollowAccepted { follower_id, following_id } => DomainEvent::FollowAccepted {
|
||||
follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
|
||||
following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?),
|
||||
},
|
||||
EventPayload::FollowRejected { follower_id, following_id } => DomainEvent::FollowRejected {
|
||||
follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
|
||||
following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?),
|
||||
},
|
||||
EventPayload::Unfollowed { follower_id, following_id } => DomainEvent::Unfollowed {
|
||||
follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
|
||||
following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?),
|
||||
},
|
||||
EventPayload::UserBlocked { blocker_id, blocked_id } => DomainEvent::UserBlocked {
|
||||
blocker_id: UserId::from_uuid(parse_uuid(&blocker_id, "blocker_id")?),
|
||||
blocked_id: UserId::from_uuid(parse_uuid(&blocked_id, "blocked_id")?),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── NatsEventPublisher ────────────────────────────────────────────────────
|
||||
|
||||
pub struct NatsEventPublisher {
|
||||
client: async_nats::Client,
|
||||
}
|
||||
|
||||
impl NatsEventPublisher {
|
||||
pub fn new(client: async_nats::Client) -> Self { Self { client } }
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EventPublisher for NatsEventPublisher {
|
||||
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
let payload = EventPayload::from(event);
|
||||
let subject = payload.subject();
|
||||
let bytes = serde_json::to_vec(&payload)
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
self.client
|
||||
.publish(subject, bytes.into())
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
// ── NatsEventConsumer ─────────────────────────────────────────────────────
|
||||
|
||||
pub struct NatsEventConsumer {
|
||||
client: async_nats::Client,
|
||||
}
|
||||
|
||||
impl NatsEventConsumer {
|
||||
pub fn new(client: async_nats::Client) -> Self { Self { client } }
|
||||
}
|
||||
|
||||
impl EventConsumer for NatsEventConsumer {
|
||||
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
|
||||
let client = self.client.clone();
|
||||
Box::pin(async_stream::try_stream! {
|
||||
let mut sub = client
|
||||
.subscribe(">")
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
use futures::StreamExt;
|
||||
while let Some(msg) = sub.next().await {
|
||||
let payload = match serde_json::from_slice::<EventPayload>(&msg.payload) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to deserialize event payload: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let event = match DomainEvent::try_from(payload) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to convert payload to domain event: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Basic NATS has no ack/nack — at-most-once delivery
|
||||
yield EventEnvelope {
|
||||
event,
|
||||
ack: Box::new(|| {}),
|
||||
nack: Box::new(|| {}),
|
||||
};
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo test -p nats`
|
||||
Expected: 2 tests pass.
|
||||
|
||||
- [ ] **Commit:**
|
||||
```bash
|
||||
git add crates/adapters/nats/
|
||||
git commit -m "feat(nats): NatsEventPublisher and NatsEventConsumer with payload conversion"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: worker — NotificationHandler + FederationHandler
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/worker/Cargo.toml`
|
||||
- Create: `crates/worker/src/handlers.rs`
|
||||
|
||||
- [ ] **Write `crates/worker/Cargo.toml`:**
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "worker"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "thoughts-worker"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
nats = { workspace = true }
|
||||
event-payload = { workspace = true }
|
||||
postgres = { workspace = true }
|
||||
async-nats = { workspace = true }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
futures = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
```
|
||||
|
||||
- [ ] **Write tests** at bottom of `crates/worker/src/handlers.rs`:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::{
|
||||
models::{thought::{Thought, Visibility}, user::User},
|
||||
testing::TestStore,
|
||||
value_objects::*,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn alice() -> User {
|
||||
User::new_local(
|
||||
UserId::new(),
|
||||
Username::new("alice").unwrap(),
|
||||
Email::new("alice@ex.com").unwrap(),
|
||||
PasswordHash("h".into()),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn like_added_creates_notification_for_thought_author() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let bob_id = UserId::new();
|
||||
|
||||
// alice posts a thought
|
||||
let thought = Thought::new_local(
|
||||
ThoughtId::new(), alice.id.clone(),
|
||||
Content::new_local("hello").unwrap(),
|
||||
None, Visibility::Public, None, false,
|
||||
);
|
||||
store.users.lock().unwrap().push(alice.clone());
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let handler = NotificationHandler {
|
||||
thoughts: Arc::new(store.clone()),
|
||||
notifications: Arc::new(store.clone()),
|
||||
};
|
||||
|
||||
// bob likes alice's thought
|
||||
handler.handle(&DomainEvent::LikeAdded {
|
||||
like_id: LikeId::new(),
|
||||
user_id: bob_id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
}).await.unwrap();
|
||||
|
||||
let notifs = store.notifications.lock().unwrap();
|
||||
assert_eq!(notifs.len(), 1);
|
||||
assert_eq!(notifs[0].user_id, alice.id); // notification goes to alice
|
||||
assert!(matches!(notifs[0].notification_type, domain::models::notification::NotificationType::Like));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn self_like_does_not_create_notification() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let thought = Thought::new_local(
|
||||
ThoughtId::new(), alice.id.clone(),
|
||||
Content::new_local("hello").unwrap(),
|
||||
None, Visibility::Public, None, false,
|
||||
);
|
||||
store.users.lock().unwrap().push(alice.clone());
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let handler = NotificationHandler {
|
||||
thoughts: Arc::new(store.clone()),
|
||||
notifications: Arc::new(store.clone()),
|
||||
};
|
||||
|
||||
handler.handle(&DomainEvent::LikeAdded {
|
||||
like_id: LikeId::new(),
|
||||
user_id: alice.id.clone(), // alice likes her own thought
|
||||
thought_id: thought.id.clone(),
|
||||
}).await.unwrap();
|
||||
|
||||
assert!(store.notifications.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn follow_accepted_creates_notification() {
|
||||
let store = TestStore::default();
|
||||
let alice = alice();
|
||||
let bob_id = UserId::new();
|
||||
store.users.lock().unwrap().push(alice.clone());
|
||||
|
||||
let handler = NotificationHandler {
|
||||
thoughts: Arc::new(store.clone()),
|
||||
notifications: Arc::new(store.clone()),
|
||||
};
|
||||
|
||||
// bob follows alice (alice gets notified)
|
||||
handler.handle(&DomainEvent::FollowAccepted {
|
||||
follower_id: bob_id.clone(),
|
||||
following_id: alice.id.clone(),
|
||||
}).await.unwrap();
|
||||
|
||||
let notifs = store.notifications.lock().unwrap();
|
||||
assert_eq!(notifs.len(), 1);
|
||||
assert_eq!(notifs[0].user_id, alice.id);
|
||||
assert!(matches!(notifs[0].notification_type, domain::models::notification::NotificationType::Follow));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo test -p worker` — Expected: FAIL (handlers.rs doesn't exist yet).
|
||||
|
||||
- [ ] **Create `crates/worker/src/handlers.rs`:**
|
||||
|
||||
```rust
|
||||
use std::sync::Arc;
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::notification::{Notification, NotificationType},
|
||||
ports::{NotificationRepository, ThoughtRepository},
|
||||
value_objects::NotificationId,
|
||||
};
|
||||
|
||||
/// Handles domain events that should create notifications for users.
|
||||
pub struct NotificationHandler {
|
||||
pub thoughts: Arc<dyn ThoughtRepository>,
|
||||
pub notifications: Arc<dyn NotificationRepository>,
|
||||
}
|
||||
|
||||
impl NotificationHandler {
|
||||
pub async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
match event {
|
||||
DomainEvent::LikeAdded { like_id: _, user_id, thought_id } => {
|
||||
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||
Some(t) => t,
|
||||
None => return Ok(()), // thought deleted — skip
|
||||
};
|
||||
if thought.user_id == *user_id { return Ok(()); } // no self-notifications
|
||||
self.notifications.save(&Notification {
|
||||
id: NotificationId::new(),
|
||||
user_id: thought.user_id,
|
||||
notification_type: NotificationType::Like,
|
||||
from_user_id: Some(user_id.clone()),
|
||||
thought_id: Some(thought_id.clone()),
|
||||
read: false,
|
||||
created_at: Utc::now(),
|
||||
}).await
|
||||
}
|
||||
DomainEvent::BoostAdded { boost_id: _, user_id, thought_id } => {
|
||||
let thought = match self.thoughts.find_by_id(thought_id).await? {
|
||||
Some(t) => t,
|
||||
None => return Ok(()),
|
||||
};
|
||||
if thought.user_id == *user_id { return Ok(()); }
|
||||
self.notifications.save(&Notification {
|
||||
id: NotificationId::new(),
|
||||
user_id: thought.user_id,
|
||||
notification_type: NotificationType::Boost,
|
||||
from_user_id: Some(user_id.clone()),
|
||||
thought_id: Some(thought_id.clone()),
|
||||
read: false,
|
||||
created_at: Utc::now(),
|
||||
}).await
|
||||
}
|
||||
DomainEvent::FollowAccepted { follower_id, following_id } => {
|
||||
// The person being followed (following_id) gets notified
|
||||
self.notifications.save(&Notification {
|
||||
id: NotificationId::new(),
|
||||
user_id: following_id.clone(),
|
||||
notification_type: NotificationType::Follow,
|
||||
from_user_id: Some(follower_id.clone()),
|
||||
thought_id: None,
|
||||
read: false,
|
||||
created_at: Utc::now(),
|
||||
}).await
|
||||
}
|
||||
// All other events: no notification needed in Plan 3
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stub handler for ActivityPub federation — implemented in Plan 4.
|
||||
pub struct FederationHandler;
|
||||
|
||||
impl FederationHandler {
|
||||
pub async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
tracing::debug!(event = ?event, "federation handler (stub — Plan 4)");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo test -p worker` — Expected: 3 tests pass.
|
||||
|
||||
- [ ] **Commit:**
|
||||
```bash
|
||||
git add crates/worker/
|
||||
git commit -m "feat(worker): NotificationHandler and FederationHandler stub"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: worker main binary
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/worker/src/main.rs`
|
||||
|
||||
- [ ] **Write `crates/worker/src/main.rs`:**
|
||||
|
||||
```rust
|
||||
mod handlers;
|
||||
|
||||
use std::sync::Arc;
|
||||
use futures::StreamExt;
|
||||
use sqlx::PgPool;
|
||||
use domain::ports::EventConsumer;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
dotenvy::dotenv().ok();
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL required");
|
||||
let nats_url = std::env::var("NATS_URL").unwrap_or_else(|_| "nats://localhost:4222".into());
|
||||
|
||||
tracing::info!("Connecting to postgres...");
|
||||
let pool = PgPool::connect(&database_url).await.expect("DB connect failed");
|
||||
|
||||
tracing::info!("Connecting to NATS at {nats_url}...");
|
||||
let nats_client = async_nats::connect(&nats_url).await.expect("NATS connect failed");
|
||||
let consumer = nats::NatsEventConsumer::new(nats_client);
|
||||
|
||||
let notification_handler = handlers::NotificationHandler {
|
||||
thoughts: Arc::new(postgres::thought::PgThoughtRepository::new(pool.clone())),
|
||||
notifications: Arc::new(postgres::notification::PgNotificationRepository::new(pool.clone())),
|
||||
};
|
||||
let federation_handler = handlers::FederationHandler;
|
||||
|
||||
tracing::info!("Worker started, consuming events...");
|
||||
|
||||
let mut stream = consumer.consume();
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(envelope) => {
|
||||
let event = &envelope.event;
|
||||
tracing::debug!(subject = ?event, "received event");
|
||||
|
||||
let n_result = notification_handler.handle(event).await;
|
||||
let f_result = federation_handler.handle(event).await;
|
||||
|
||||
if n_result.is_ok() && f_result.is_ok() {
|
||||
(envelope.ack)();
|
||||
} else {
|
||||
if let Err(e) = n_result { tracing::error!("notification handler error: {e}"); }
|
||||
if let Err(e) = f_result { tracing::error!("federation handler error: {e}"); }
|
||||
(envelope.nack)();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("consumer error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo build -p worker`
|
||||
Expected: compiles cleanly (binary `thoughts-worker` produced).
|
||||
|
||||
- [ ] **Smoke test** (requires NATS running):
|
||||
```bash
|
||||
# Terminal 1: start NATS if not already running
|
||||
docker run -d --name nats -p 4222:4222 nats:latest || true
|
||||
|
||||
# Terminal 2: start worker
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres \
|
||||
RUST_LOG=info \
|
||||
cargo run --bin thoughts-worker &
|
||||
sleep 2
|
||||
|
||||
# Terminal 3: start API server
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres JWT_SECRET=dev cargo run -p presentation &
|
||||
sleep 2
|
||||
|
||||
# Create a user, post a thought, like it — check that worker logs "received event"
|
||||
TOKEN=$(curl -s -X POST http://localhost:3000/auth/register \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"username":"evttest","email":"evt@test.com","password":"pw"}' | jq -r .token)
|
||||
|
||||
TID=$(curl -s -X POST http://localhost:3000/thoughts \
|
||||
-H 'content-type: application/json' \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"content":"event test"}' | jq -r .id)
|
||||
|
||||
curl -s -X POST http://localhost:3000/thoughts/$TID/like \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
kill %1 %2 2>/dev/null
|
||||
```
|
||||
|
||||
Expected: worker logs show `received event` for the like. No errors.
|
||||
|
||||
- [ ] **Commit:**
|
||||
```bash
|
||||
git add crates/worker/src/main.rs
|
||||
git commit -m "feat(worker): consumer loop binary connecting NATS to handlers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Presentation — swap NoOp for real NatsEventPublisher
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/presentation/Cargo.toml`
|
||||
- Modify: `crates/presentation/src/lib.rs`
|
||||
- Modify: `crates/presentation/src/main.rs`
|
||||
|
||||
When NATS_URL is not set, fall back to the `NoOpEventPublisher` so the API still starts without NATS. Use an env var `NATS_URL` — if set, use real publisher; if absent, log a warning and use no-op.
|
||||
|
||||
- [ ] **Add `nats` to `crates/presentation/Cargo.toml` deps:**
|
||||
|
||||
```toml
|
||||
nats = { workspace = true }
|
||||
async-nats = { workspace = true }
|
||||
```
|
||||
|
||||
- [ ] **Update `crates/presentation/src/lib.rs`** — replace the `NoOpEventPublisher` struct and `build_state` function with one that optionally connects to NATS:
|
||||
|
||||
Replace the existing `build_state` signature with an async version:
|
||||
|
||||
```rust
|
||||
use std::sync::Arc;
|
||||
use sqlx::PgPool;
|
||||
use state::AppState;
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher};
|
||||
|
||||
pub mod errors;
|
||||
pub mod extractors;
|
||||
pub mod handlers;
|
||||
pub mod routes;
|
||||
pub mod state;
|
||||
|
||||
use postgres_search::PgSearchRepository;
|
||||
|
||||
struct NoOpEventPublisher;
|
||||
#[async_trait]
|
||||
impl EventPublisher for NoOpEventPublisher {
|
||||
async fn publish(&self, _e: &DomainEvent) -> Result<(), DomainError> { Ok(()) }
|
||||
}
|
||||
|
||||
pub async fn build_state(pool: PgPool, jwt_secret: String) -> AppState {
|
||||
let event_publisher: Arc<dyn EventPublisher> = match std::env::var("NATS_URL") {
|
||||
Ok(url) => {
|
||||
match async_nats::connect(&url).await {
|
||||
Ok(client) => {
|
||||
tracing::info!("Connected to NATS at {url}");
|
||||
Arc::new(nats::NatsEventPublisher::new(client))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to connect to NATS at {url}: {e} — using no-op publisher");
|
||||
Arc::new(NoOpEventPublisher)
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::info!("NATS_URL not set — using no-op event publisher");
|
||||
Arc::new(NoOpEventPublisher)
|
||||
}
|
||||
};
|
||||
|
||||
AppState {
|
||||
users: Arc::new(postgres::user::PgUserRepository::new(pool.clone())),
|
||||
thoughts: Arc::new(postgres::thought::PgThoughtRepository::new(pool.clone())),
|
||||
likes: Arc::new(postgres::like::PgLikeRepository::new(pool.clone())),
|
||||
boosts: Arc::new(postgres::boost::PgBoostRepository::new(pool.clone())),
|
||||
follows: Arc::new(postgres::follow::PgFollowRepository::new(pool.clone())),
|
||||
blocks: Arc::new(postgres::block::PgBlockRepository::new(pool.clone())),
|
||||
tags: Arc::new(postgres::tag::PgTagRepository::new(pool.clone())),
|
||||
api_keys: Arc::new(postgres::api_key::PgApiKeyRepository::new(pool.clone())),
|
||||
top_friends: Arc::new(postgres::top_friend::PgTopFriendRepository::new(pool.clone())),
|
||||
notifications: Arc::new(postgres::notification::PgNotificationRepository::new(pool.clone())),
|
||||
remote_actors: Arc::new(postgres::remote_actor::PgRemoteActorRepository::new(pool.clone())),
|
||||
feed: Arc::new(postgres::feed::PgFeedRepository::new(pool.clone())),
|
||||
search: Arc::new(PgSearchRepository::new(pool.clone())),
|
||||
auth: Arc::new(auth::JwtAuthService::new(jwt_secret, 86400 * 30)),
|
||||
hasher: Arc::new(auth::Argon2PasswordHasher),
|
||||
events: event_publisher,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Update `crates/presentation/src/main.rs`** — `build_state` is now async, so await it:
|
||||
|
||||
```rust
|
||||
use sqlx::PgPool;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
dotenvy::dotenv().ok();
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL required");
|
||||
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET required");
|
||||
let port = std::env::var("PORT").unwrap_or_else(|_| "3000".into());
|
||||
|
||||
let pool = PgPool::connect(&database_url).await.expect("DB connect failed");
|
||||
sqlx::migrate!("../adapters/postgres/migrations").run(&pool).await.expect("Migrations failed");
|
||||
|
||||
let state = presentation::build_state(pool, jwt_secret).await; // note: .await
|
||||
let app = presentation::routes::router()
|
||||
.with_state(state)
|
||||
.layer(CorsLayer::permissive());
|
||||
|
||||
let addr = format!("0.0.0.0:{port}");
|
||||
tracing::info!("Listening on {addr}");
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Run:** `cargo build -p presentation`
|
||||
Expected: clean build.
|
||||
|
||||
- [ ] **Verify no-op fallback works** (without NATS running):
|
||||
```bash
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres JWT_SECRET=dev \
|
||||
RUST_LOG=info cargo run -p presentation &
|
||||
sleep 2
|
||||
# Should log: "NATS_URL not set — using no-op event publisher"
|
||||
curl -s -X POST http://localhost:3000/auth/register \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"username":"natstest","email":"nats@test.com","password":"pw"}' | jq .token
|
||||
kill %1
|
||||
```
|
||||
|
||||
- [ ] **Run full test suite:**
|
||||
```bash
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5434/postgres cargo test --workspace
|
||||
```
|
||||
Expected: all tests pass (52 + new worker tests = 55+).
|
||||
|
||||
- [ ] **Commit:**
|
||||
```bash
|
||||
git add crates/presentation/
|
||||
git commit -m "feat(presentation): NatsEventPublisher with no-op fallback when NATS_URL unset"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- ✅ event-payload: serializable EventPayload enum, subject(), From/TryFrom conversions (Task 1)
|
||||
- ✅ nats: NatsEventPublisher implementing EventPublisher (Task 2)
|
||||
- ✅ nats: NatsEventConsumer implementing EventConsumer via BoxStream (Task 2)
|
||||
- ✅ worker: NotificationHandler (LikeAdded, BoostAdded, FollowAccepted → notifications) (Task 3)
|
||||
- ✅ worker: FederationHandler stub (Task 3)
|
||||
- ✅ worker: consumer loop binary (Task 4)
|
||||
- ✅ presentation: real NATS publisher with graceful no-op fallback (Task 5)
|
||||
- ✅ event-publisher: stays as stub (correct — deferred per plan)
|
||||
|
||||
**Placeholder scan:** None — all code blocks complete.
|
||||
|
||||
**Type consistency:**
|
||||
- `NatsEventPublisher::new(client: async_nats::Client)` — matches usage in presentation lib.rs and worker main.rs
|
||||
- `NatsEventConsumer::new(client: async_nats::Client)` — matches worker main.rs
|
||||
- `NotificationHandler { thoughts, notifications }` — field names match handler usage in main.rs
|
||||
- `build_state` is now `async fn` — main.rs correctly awaits it
|
||||
- `EventPayload::from(&DomainEvent)` — implemented in nats crate (which sees both types)
|
||||
|
||||
**Notes:**
|
||||
- Basic NATS (at-most-once delivery) is used — JetStream (exactly-once) deferred to later
|
||||
- Worker Cargo.toml includes `postgres` internal crate for database access in handlers
|
||||
- `crates/adapters/nats` has Rust module name `nats` but package name `nats` — import as `use nats::...`
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,118 +0,0 @@
|
||||
# REST API Cleanup Design
|
||||
|
||||
Clean up the REST API to be professional, consistent, and RESTful. No new features — only renames, unifications, and content negotiation.
|
||||
|
||||
## Route Changes
|
||||
|
||||
| Before | After | Reason |
|
||||
|--------|-------|--------|
|
||||
| `GET /users/{username}/profile` | `GET /users/{username}` | content negotiation replaces the /profile workaround |
|
||||
| `GET /federation/lookup?handle=` | `GET /users/lookup?handle=` | federation lookup belongs under /users |
|
||||
| `POST /users/{id}/follow` | `POST /users/{username}/follow` | param was mislabelled; now also handles remote follows |
|
||||
| `DELETE /users/{id}/follow` | `DELETE /users/{username}/follow` | param rename |
|
||||
| `POST /users/{id}/block` | `POST /users/{username}/block` | param rename |
|
||||
| `DELETE /users/{id}/block` | `DELETE /users/{username}/block` | param rename |
|
||||
| `GET /users/{username}/follower-list` | `GET /users/{username}/followers` | verbose name |
|
||||
| `GET /users/{username}/following-list` | `GET /users/{username}/following` | verbose name |
|
||||
| `GET /users/me/following-list` | `GET /users/me/following` | verbose name |
|
||||
| `POST /notifications/{id}/read` | `PATCH /notifications/{id}` | POST for state change → PATCH |
|
||||
| `POST /notifications/read-all` | `PATCH /notifications` | POST bulk action → PATCH |
|
||||
| `PUT /users/me` | removed | `PATCH /users/me` is sufficient |
|
||||
| `POST /federation/follow` | removed | unified into `POST /users/{username}/follow` |
|
||||
|
||||
## Content Negotiation at `GET /users/{username}`
|
||||
|
||||
The AP router currently owns `/users/{username}` (returns `application/activity+json`). The REST profile was at `/users/{username}/profile` as a workaround.
|
||||
|
||||
**Solution:** Remove `/users/{username}` from the AP router. Add a single handler at `GET /users/{username}` in the REST router that checks the `Accept` header:
|
||||
|
||||
- `Accept: application/activity+json` → return AP actor JSON with `Content-Type: application/activity+json`
|
||||
- Anything else → return `UserResponse` with `Content-Type: application/json`
|
||||
|
||||
**Implementation:**
|
||||
|
||||
Add `actor_json(&self, user_id: &UserId) -> Result<String, DomainError>` to `FederationActionPort` in domain. Implement in `ActivityPubService` by delegating to the existing `self.actor_json(&user_id.as_uuid().to_string())` inherent method.
|
||||
|
||||
The unified handler in `presentation/src/handlers/users.rs`:
|
||||
1. Looks up user by username via `UserRepository` → 404 if not found
|
||||
2. Checks `Accept` header
|
||||
3. AP path: calls `s.federation.actor_json(&user.id)` → returns with `Content-Type: application/activity+json`
|
||||
4. REST path: returns `UserResponse` as before
|
||||
|
||||
The AP router in `bootstrap/src/main.rs` no longer registers `/users/{username}`.
|
||||
|
||||
## Unified Follow at `POST /users/{username}/follow`
|
||||
|
||||
The handler detects whether `{username}` is a local user or a remote actor:
|
||||
|
||||
```rust
|
||||
if username.contains('@') {
|
||||
// Remote: e.g. "gabrielkaszewski@mastodon.social"
|
||||
s.federation.follow_remote(&uid, &username).await?;
|
||||
} else {
|
||||
// Local: look up by username, call follow_user use case
|
||||
let target = get_user_by_username(&*s.users, &username).await?;
|
||||
follow_user(&*s.follows, &*s.events, &uid, &target.id).await?;
|
||||
}
|
||||
```
|
||||
|
||||
`POST /federation/follow` and `federation::follow_remote_handler` are deleted.
|
||||
|
||||
## Remote Actor Handle Format Fix
|
||||
|
||||
`lookup_actor` currently returns `handle: actor.username` (just `preferred_username`, e.g. `gabrielkaszewski`). Fix: return the full `user@domain` handle by extracting the domain from `actor.ap_id`:
|
||||
|
||||
```rust
|
||||
let domain = actor.ap_id.host_str().unwrap_or("");
|
||||
let full_handle = format!("{}@{}", actor.username, domain);
|
||||
// RemoteActor { handle: full_handle, ... }
|
||||
```
|
||||
|
||||
This means `RemoteActorResponse.handle` = `"gabrielkaszewski@mastodon.social"`, which the frontend passes directly to `POST /users/gabrielkaszewski@mastodon.social/follow`.
|
||||
|
||||
## Remote Unfollow Scope
|
||||
|
||||
`DELETE /users/{username}/follow` for a remote handle (contains `@`) is **out of scope**. The handler returns `501 Not Implemented` when `username` contains `@`. Remote unfollow requires an `Undo Follow` ActivityPub activity and is a separate feature.
|
||||
|
||||
## Notification Endpoints
|
||||
|
||||
Add `NotificationUpdateRequest { read: bool }` to `api-types/src/requests.rs`.
|
||||
|
||||
- `PATCH /notifications/{id}` — mark single notification read (body: `{"read": true}`)
|
||||
- `PATCH /notifications` — mark all notifications read (body: `{"read": true}`)
|
||||
|
||||
Both replace their existing `POST` counterparts.
|
||||
|
||||
## Frontend (`thoughts-frontend/lib/api.ts`)
|
||||
|
||||
| Function | Change |
|
||||
|----------|--------|
|
||||
| `getUserProfile(username)` | URL: `/users/${username}/profile` → `/users/${username}` |
|
||||
| `getFollowersList(username)` | URL: `/follower-list` → `/followers` |
|
||||
| `getFollowingList(username)` | URL: `/following-list` → `/following` |
|
||||
| `getMeFollowingList()` | URL: `/me/following-list` → `/me/following` |
|
||||
| `lookupRemoteActor(handle)` | URL: `/federation/lookup?handle=` → `/users/lookup?handle=` |
|
||||
| `followRemoteUser(handle)` | **Deleted** — use unified `followUser(handle)` instead |
|
||||
| `markNotificationRead(id)` | **New** — `PATCH /notifications/{id}` with body `{"read":true}` (no prior frontend impl) |
|
||||
| `markAllNotificationsRead()` | **New** — `PATCH /notifications` with body `{"read":true}` (no prior frontend impl) |
|
||||
|
||||
Also update `remote-user-card.tsx` to call `followUser(actor.handle, token)` instead of `followRemoteUser`.
|
||||
|
||||
## Files Touched
|
||||
|
||||
**Backend:**
|
||||
- `crates/domain/src/ports.rs` — add `actor_json` to `FederationActionPort`
|
||||
- `crates/domain/src/testing.rs` — add `actor_json` to `TestStore` impl
|
||||
- `crates/adapters/activitypub-base/src/service.rs` — add `actor_json` to `FederationActionPort` impl; fix `lookup_actor` handle format
|
||||
- `crates/presentation/src/handlers/users.rs` — unified `GET /users/{username}` handler; remove old `get_user` (was /profile)
|
||||
- `crates/presentation/src/handlers/social.rs` — unify `post_follow`; rename `{id}` → `{username}` in follow/block; rename follower/following list handlers
|
||||
- `crates/presentation/src/handlers/federation.rs` — delete `follow_remote_handler`; move `lookup_handler` to `users.rs`; delete file if empty
|
||||
- `crates/presentation/src/handlers/notifications.rs` — replace read handlers with PATCH
|
||||
- `crates/presentation/src/routes.rs` — all route changes
|
||||
- `crates/api-types/src/requests.rs` — add `NotificationUpdateRequest`
|
||||
- `crates/bootstrap/src/main.rs` — remove `/users/{username}` from ap_router
|
||||
|
||||
**Frontend:**
|
||||
- `thoughts-frontend/lib/api.ts` — all URL/method changes listed above
|
||||
- `thoughts-frontend/components/remote-user-card.tsx` — use `followUser` instead of `followRemoteUser`
|
||||
- Any page that calls `getFollowersList`, `getFollowingList`, `getMeFollowingList`, `markNotificationRead`, `markAllNotificationsRead` (check all pages under `app/`)
|
||||
@@ -1,300 +0,0 @@
|
||||
# Remote Actor Profile Design
|
||||
|
||||
Display full profiles for remote ActivityPub actors: metadata (avatar, bio, banner, profile fields) plus their public posts, fetched in the background via the NATS worker.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. User navigates to `/users/@gabrielkaszewski@mastodon.social`
|
||||
2. Frontend detects `@user@domain` format, calls in parallel:
|
||||
- `GET /users/lookup?handle=@user@instance` → enriched profile metadata
|
||||
- `GET /federation/actors/{handle}/posts?page=1` → cached posts (empty on first visit)
|
||||
3. Posts endpoint: looks up interned local `UserId`, queries `feed.user_feed`, **then** publishes `DomainEvent::FetchRemoteActorPosts { actor_ap_url, outbox_url }` fire-and-forget
|
||||
4. Worker receives event → fetches remote outbox page via HTTP → stores public notes via `ap_repo.accept_note`
|
||||
5. On next visit/refresh posts are populated
|
||||
|
||||
## Domain Changes
|
||||
|
||||
### Extend `domain/src/models/remote_actor.rs`
|
||||
|
||||
Add fields:
|
||||
```rust
|
||||
pub struct RemoteActor {
|
||||
pub url: String,
|
||||
pub handle: String,
|
||||
pub display_name: Option<String>,
|
||||
pub inbox_url: String,
|
||||
pub shared_inbox_url: Option<String>,
|
||||
pub public_key: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub last_fetched_at: DateTime<Utc>,
|
||||
// new:
|
||||
pub bio: Option<String>,
|
||||
pub banner_url: Option<String>,
|
||||
pub also_known_as: Option<String>,
|
||||
pub outbox_url: Option<String>,
|
||||
pub attachment: Vec<(String, String)>, // (name, value)
|
||||
}
|
||||
```
|
||||
|
||||
### New `domain/src/models/remote_note.rs`
|
||||
|
||||
```rust
|
||||
pub struct RemoteNote {
|
||||
pub ap_id: String,
|
||||
pub content: String,
|
||||
pub published: chrono::DateTime<chrono::Utc>,
|
||||
pub sensitive: bool,
|
||||
pub content_warning: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
### New `DomainEvent` variant (`domain/src/events.rs`)
|
||||
|
||||
```rust
|
||||
FetchRemoteActorPosts {
|
||||
actor_ap_url: String,
|
||||
outbox_url: String,
|
||||
}
|
||||
```
|
||||
|
||||
### New `FederationActionPort` method (`domain/src/ports.rs`)
|
||||
|
||||
```rust
|
||||
async fn fetch_outbox_page(
|
||||
&self,
|
||||
outbox_url: &str,
|
||||
page: u32,
|
||||
) -> Result<Vec<RemoteNote>, DomainError>;
|
||||
```
|
||||
|
||||
`TestStore` stub returns `Ok(vec![])`.
|
||||
|
||||
## activitypub-base Implementation
|
||||
|
||||
### `lookup_actor` — populate new `RemoteActor` fields
|
||||
|
||||
Map from `DbActor`:
|
||||
```rust
|
||||
bio: actor.bio.clone(),
|
||||
banner_url: actor.banner_url.as_ref().map(|u| u.to_string()),
|
||||
also_known_as: actor.also_known_as.clone(),
|
||||
outbox_url: Some(actor.outbox_url.to_string()),
|
||||
attachment: actor.attachment.iter().map(|f| (f.name.clone(), f.value.clone())).collect(),
|
||||
```
|
||||
|
||||
### `fetch_outbox_page` impl on `ActivityPubService`
|
||||
|
||||
```rust
|
||||
async fn fetch_outbox_page(&self, outbox_url: &str, page: u32) -> Result<Vec<RemoteNote>, DomainError> {
|
||||
let url = format!("{}?page={}", outbox_url, page);
|
||||
let resp: serde_json::Value = reqwest::Client::new()
|
||||
.get(&url)
|
||||
.header("Accept", "application/activity+json, application/ld+json")
|
||||
.send().await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?
|
||||
.json().await
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||
|
||||
let items = resp["orderedItems"].as_array().cloned().unwrap_or_default();
|
||||
Ok(items.iter().filter_map(|item| {
|
||||
// Items are Create activities or Notes directly
|
||||
let note = if item["type"].as_str() == Some("Create") {
|
||||
&item["object"]
|
||||
} else if item["type"].as_str() == Some("Note") {
|
||||
item
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
// Only public notes
|
||||
let to = note["to"].as_array()?;
|
||||
let is_public = to.iter().any(|t| {
|
||||
t.as_str() == Some("https://www.w3.org/ns/activitystreams#Public")
|
||||
});
|
||||
if !is_public { return None; }
|
||||
Some(RemoteNote {
|
||||
ap_id: note["id"].as_str()?.to_string(),
|
||||
content: note["content"].as_str().unwrap_or("").to_string(),
|
||||
published: chrono::DateTime::parse_from_rfc3339(
|
||||
note["published"].as_str()?
|
||||
).ok()?.with_timezone(&chrono::Utc),
|
||||
sensitive: note["sensitive"].as_bool().unwrap_or(false),
|
||||
content_warning: note["summary"].as_str().map(|s| s.to_string()),
|
||||
})
|
||||
}).collect())
|
||||
}
|
||||
```
|
||||
|
||||
## AppState + Bootstrap
|
||||
|
||||
Add `ap_repo: Arc<dyn ActivityPubRepository>` to `presentation/src/state.rs`.
|
||||
|
||||
Wire in `bootstrap/src/factory.rs`:
|
||||
```rust
|
||||
ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())),
|
||||
```
|
||||
|
||||
## event-payload
|
||||
|
||||
Add to `EventPayload` enum:
|
||||
```rust
|
||||
FetchRemoteActorPosts {
|
||||
actor_ap_url: String,
|
||||
outbox_url: String,
|
||||
}
|
||||
```
|
||||
|
||||
Add subject (`"fetch_remote_actor_posts"`), mapping from/to `DomainEvent`, and a sample in the uniqueness test.
|
||||
|
||||
## REST Endpoint
|
||||
|
||||
**`GET /federation/actors/{handle}/posts?page=1`** (new handler in `presentation/src/handlers/federation_actors.rs`):
|
||||
|
||||
```rust
|
||||
pub async fn remote_actor_posts_handler(
|
||||
State(s): State<AppState>,
|
||||
Path(handle): Path<String>,
|
||||
Query(q): Query<PaginationQuery>,
|
||||
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let actor = s.federation.lookup_actor(&handle).await?;
|
||||
let ap_url = url::Url::parse(&actor.url)
|
||||
.map_err(|e| ApiError::BadRequest(e.to_string()))?;
|
||||
|
||||
// Get or create interned local UserId for this remote actor
|
||||
let author_id = match s.ap_repo.find_remote_actor_id(&ap_url).await? {
|
||||
Some(id) => id,
|
||||
None => s.ap_repo.intern_remote_actor(&ap_url).await?,
|
||||
};
|
||||
|
||||
// Return cached posts
|
||||
let page = PageParams { page: q.page(), per_page: q.per_page() };
|
||||
let result = s.feed.user_feed(&author_id, &page, viewer.as_ref()).await?;
|
||||
|
||||
// Trigger background fetch (fire and forget)
|
||||
if let Some(outbox_url) = &actor.outbox_url {
|
||||
let _ = s.events.publish(&DomainEvent::FetchRemoteActorPosts {
|
||||
actor_ap_url: actor.url.clone(),
|
||||
outbox_url: outbox_url.clone(),
|
||||
}).await;
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"total": result.total,
|
||||
"page": result.page,
|
||||
"per_page": result.per_page,
|
||||
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
```
|
||||
|
||||
Mount at `GET /federation/actors/{handle}/posts` in `routes.rs`.
|
||||
|
||||
Add `pub mod federation_actors;` to `handlers/mod.rs`.
|
||||
|
||||
Make `to_thought_response` in `feed.rs` `pub` so `federation_actors.rs` can import it.
|
||||
|
||||
## api-types
|
||||
|
||||
Extend `RemoteActorResponse`:
|
||||
```rust
|
||||
pub struct RemoteActorResponse {
|
||||
pub handle: String,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub url: String,
|
||||
// new:
|
||||
pub bio: Option<String>,
|
||||
pub banner_url: Option<String>,
|
||||
pub also_known_as: Option<String>,
|
||||
pub outbox_url: Option<String>,
|
||||
pub attachment: Vec<ProfileField>,
|
||||
}
|
||||
|
||||
pub struct ProfileField {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
```
|
||||
|
||||
Update `lookup_handler` in `users.rs` to populate all new fields.
|
||||
|
||||
## Worker
|
||||
|
||||
### `FederationEventService` new deps
|
||||
|
||||
Add `federation: Arc<dyn FederationActionPort>` and `ap_repo: Arc<dyn ActivityPubRepository>` to `FederationEventService`. Handle the new event:
|
||||
|
||||
```rust
|
||||
DomainEvent::FetchRemoteActorPosts { actor_ap_url, outbox_url } => {
|
||||
let notes = match self.federation.fetch_outbox_page(outbox_url, 1).await {
|
||||
Ok(n) => n,
|
||||
Err(e) => { tracing::warn!("failed to fetch outbox: {e}"); return Ok(()); }
|
||||
};
|
||||
let actor_url = url::Url::parse(actor_ap_url)
|
||||
.map_err(|e| DomainError::ExternalService(e.to_string()))?;
|
||||
let author_id = self.ap_repo.intern_remote_actor(&actor_url).await?;
|
||||
for note in notes {
|
||||
let ap_id = match url::Url::parse(¬e.ap_id) {
|
||||
Ok(u) => u,
|
||||
Err(_) => continue,
|
||||
};
|
||||
// accept_note is idempotent — ignore duplicate errors
|
||||
let _ = self.ap_repo.accept_note(
|
||||
&ap_id, &author_id, ¬e.content, note.published,
|
||||
note.sensitive, note.content_warning, "public",
|
||||
).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
Wire new deps in `worker/src/factory.rs`.
|
||||
|
||||
## Frontend
|
||||
|
||||
### `lib/api.ts`
|
||||
|
||||
```typescript
|
||||
// Enriched RemoteActorSchema (same endpoint, more fields)
|
||||
export const ProfileFieldSchema = z.object({
|
||||
name: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
export const RemoteActorSchema = z.object({
|
||||
handle: z.string(),
|
||||
displayName: z.string().nullable(),
|
||||
avatarUrl: z.string().nullable(),
|
||||
url: z.string(),
|
||||
bio: z.string().nullable(),
|
||||
bannerUrl: z.string().nullable(),
|
||||
alsoKnownAs: z.string().nullable(),
|
||||
outboxUrl: z.string().nullable(),
|
||||
attachment: z.array(ProfileFieldSchema),
|
||||
});
|
||||
|
||||
export const getRemoteActorPosts = (handle: string, page: number, token: string | null) =>
|
||||
apiFetch(
|
||||
`/federation/actors/${encodeURIComponent(handle)}/posts?page=${page}&per_page=20`,
|
||||
{},
|
||||
z.object({ total: z.number(), page: z.number(), per_page: z.number(), items: z.array(ThoughtSchema) }),
|
||||
token
|
||||
);
|
||||
```
|
||||
|
||||
### `app/users/[username]/page.tsx`
|
||||
|
||||
Detect `@user@domain` regex. If handle: call `lookupRemoteActor` + `getRemoteActorPosts` in parallel; render `<RemoteUserProfile>`. Otherwise: existing local profile.
|
||||
|
||||
### New `components/remote-user-profile.tsx`
|
||||
|
||||
Client component showing:
|
||||
- Banner (`bannerUrl`) — full-width image or placeholder
|
||||
- Avatar + display name + handle (`@user@instance`)
|
||||
- Bio (rendered as text)
|
||||
- Profile fields (`attachment`) — key-value table
|
||||
- "Also known as" link (if present)
|
||||
- External profile link button → `url` in new tab
|
||||
- Follow button (reuse `followUser(handle, token)`)
|
||||
- Posts list using `ThoughtList` or similar, with empty state "Posts are loading, check back soon"
|
||||
- Pagination controls
|
||||
@@ -1,81 +0,0 @@
|
||||
# Remote Actor Search & Follow
|
||||
|
||||
Allows local users to search for and follow users on other ActivityPub instances (e.g. `@user@mastodon.social`) directly from the existing search page.
|
||||
|
||||
## Architecture
|
||||
|
||||
Approach A: new `FederationActionPort` domain trait + dedicated `/federation/*` REST endpoints. Keeps hexagonal arch intact — presentation has no dep on `activitypub-base`.
|
||||
|
||||
## Domain changes
|
||||
|
||||
**`domain/src/models/remote_actor.rs`** — add `avatar_url: Option<String>`
|
||||
|
||||
**`domain/src/errors.rs`** — add `ExternalService(String)` variant
|
||||
|
||||
**`domain/src/ports.rs`** — new trait:
|
||||
|
||||
```rust
|
||||
pub trait FederationActionPort: Send + Sync {
|
||||
async fn lookup_actor(&self, handle: &str) -> Result<RemoteActor, DomainError>;
|
||||
async fn follow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError>;
|
||||
}
|
||||
```
|
||||
|
||||
## activitypub-base impl
|
||||
|
||||
`impl domain::ports::FederationActionPort for ActivityPubService` in `service.rs`:
|
||||
|
||||
- `lookup_actor`: calls `webfinger_resolve_actor(handle, &data)` → maps `DbActor` to `domain::RemoteActor`
|
||||
- `follow_remote`: delegates to existing `self.follow(local_user_id.inner(), handle)` (already handles WebFinger + Follow activity + federation DB record)
|
||||
|
||||
## Bootstrap refactor
|
||||
|
||||
`factory.rs` currently builds `FederationData` + `ApFederationConfig` directly. Switch to `ActivityPubService::new(...)` which creates both internally. `Infrastructure` holds `Arc<ActivityPubService>` instead of `ApFederationConfig`. `main.rs` uses `infra.ap_service.federation_config().middleware()`.
|
||||
|
||||
`AppState` gets one new field:
|
||||
|
||||
```rust
|
||||
pub federation: Arc<dyn FederationActionPort>,
|
||||
```
|
||||
|
||||
Wired to `Arc::clone(&ap_service)` in factory.
|
||||
|
||||
## REST endpoints
|
||||
|
||||
**`api-types/src/responses.rs`** — new:
|
||||
```rust
|
||||
pub struct RemoteActorResponse {
|
||||
pub handle: String,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub url: String,
|
||||
}
|
||||
```
|
||||
|
||||
**`presentation/src/handlers/federation.rs`** (new file):
|
||||
|
||||
| Method | Path | Auth | Body | Response |
|
||||
|--------|------|------|------|----------|
|
||||
| GET | `/federation/lookup?handle=@user@instance.tld` | none | — | `RemoteActorResponse` |
|
||||
| POST | `/federation/follow` | bearer | `{"handle":"@user@instance.tld"}` | 204 |
|
||||
|
||||
Mounted in `routes.rs` under `/federation`.
|
||||
|
||||
Error mapping: `DomainError::ExternalService` → 502, `DomainError::NotFound` → 404.
|
||||
|
||||
## Frontend
|
||||
|
||||
**`lib/api.ts`**:
|
||||
- `RemoteActorSchema` + `RemoteActor` type
|
||||
- `lookupRemoteActor(handle, token)` → `GET /federation/lookup?handle=...`
|
||||
- `followRemoteUser(handle, token)` → `POST /federation/follow`
|
||||
|
||||
**`app/search/page.tsx`**:
|
||||
- Detect `@user@instance.tld` via regex `/^@[\w.-]+@[\w.-]+\.\w+$/`
|
||||
- If matches: call `lookupRemoteActor` in parallel with local search
|
||||
- Pass remote actor result to component; show in Users tab above local results
|
||||
|
||||
**`components/remote-user-card.tsx`** (new client component):
|
||||
- Displays avatar, handle, display name
|
||||
- Follow button calls `followRemoteUser(handle, token)`
|
||||
- No unfollow needed for MVP (remote following status not tracked locally)
|
||||
@@ -1,285 +0,0 @@
|
||||
# Thoughts v2 — Architecture Rewrite Design
|
||||
|
||||
## Context
|
||||
|
||||
Thoughts is a federated social web service currently running on a monolithic axum + Sea-ORM backend with no domain layer, no traits, and tightly coupled persistence. v2 is a full rewrite targeting:
|
||||
|
||||
- Hexagonal architecture (ports & adapters, zero leakage between layers)
|
||||
- Full bidirectional ActivityPub federation (Mastodon-compatible Fediverse citizen)
|
||||
- sqlx with raw SQL — no ORM
|
||||
- Postgres only (for now), but no coupling to any concrete adapter
|
||||
- Crate structure mirroring movies-diary (the reference implementation)
|
||||
- Production data must survive cutover via additive migrations
|
||||
|
||||
---
|
||||
|
||||
## Crate Structure
|
||||
|
||||
```
|
||||
crates/
|
||||
domain/ # entities, value objects, ports (traits), domain events
|
||||
application/ # use cases (commands + queries), no framework deps
|
||||
api-types/ # request/response DTOs, shared serializable types
|
||||
presentation/ # axum handlers, routes, extractors, state, openapi — JSON REST only, no HTML rendering (client is Next.js)
|
||||
worker/ # event consumer loop, dispatches to event handlers
|
||||
adapters/
|
||||
postgres/ # sqlx impls of all repos + migrations/
|
||||
postgres-search/ # SearchPort via pg_trgm / tsvector
|
||||
postgres-federation/ # federation-specific queries (known actors, etc.)
|
||||
activitypub-base/ # copied from movies-diary — signing, WebFinger, NodeInfo
|
||||
activitypub/ # thoughts-specific AP objects (Note, Person) + activity handlers
|
||||
auth/ # JWT AuthService impl
|
||||
nats/ # EventPublisher + EventConsumer via NATS
|
||||
event-payload/ # serializable event envelope types (NATS wire format)
|
||||
event-publisher/ # event routing — domain events → NATS subjects
|
||||
```
|
||||
|
||||
**Dependency rule:** `domain` has zero external deps. `application` depends only on `domain`. All adapters depend on `domain` traits only — never on each other. `presentation` and `worker` wire concrete adapters into `Arc<dyn Port>` and inject via state. `presentation` never imports from `postgres` directly.
|
||||
|
||||
---
|
||||
|
||||
## Domain Model
|
||||
|
||||
### Entities & Value Objects
|
||||
|
||||
```
|
||||
User — UserId, Username, Email, PasswordHash, DisplayName, Bio,
|
||||
AvatarUrl, HeaderUrl, local: bool, ap_id: Url,
|
||||
public_key: String, private_key: Option<String> (None for remote)
|
||||
|
||||
Thought — ThoughtId, UserId, Content (≤128 chars local / unlimited remote),
|
||||
in_reply_to: Option<ThoughtId | RemoteUrl>, ap_id: Url,
|
||||
visibility: Public|Followers|Unlisted|Direct,
|
||||
content_warning: Option<String>, sensitive: bool, local: bool
|
||||
|
||||
Like — LikeId, UserId, ThoughtId, ap_id: Url
|
||||
Boost — BoostId, UserId, ThoughtId, ap_id: Url
|
||||
Follow — FollowerId, FollowingId, state: Pending|Accepted|Rejected, ap_id: Url
|
||||
Block — BlockerId, BlockedId
|
||||
Tag — TagId, name
|
||||
ApiKey — ApiKeyId, UserId, key_hash, name
|
||||
TopFriend — UserId, FriendId, position (1–8)
|
||||
RemoteActor — url, handle, display_name, inbox_url, shared_inbox_url, public_key
|
||||
```
|
||||
|
||||
### Ports (traits in domain, implemented by adapters)
|
||||
|
||||
`UserRepository`, `ThoughtRepository`, `LikeRepository`, `BoostRepository`,
|
||||
`FollowRepository`, `BlockRepository`, `TagRepository`, `ApiKeyRepository`,
|
||||
`TopFriendRepository`, `RemoteActorRepository`, `AuthService`, `PasswordHasher`,
|
||||
`EventPublisher`, `EventConsumer`, `SearchPort`, `SearchCommand`
|
||||
|
||||
### Domain Events
|
||||
|
||||
Published after mutations, consumed by worker for federation and side-effects:
|
||||
|
||||
`ThoughtCreated`, `ThoughtDeleted`, `ThoughtUpdated`,
|
||||
`LikeAdded`, `LikeRemoved`,
|
||||
`BoostAdded`, `BoostRemoved`,
|
||||
`FollowRequested`, `FollowAccepted`, `FollowRejected`, `Unfollowed`,
|
||||
`UserBlocked`
|
||||
|
||||
---
|
||||
|
||||
## Application Layer (Use Cases)
|
||||
|
||||
Each use case lives in `application/src/use_cases/` and receives only `&dyn Port` references — no framework types, no sqlx, no axum. Fully testable with mock impls.
|
||||
|
||||
**Commands** (mutate state, publish domain event):
|
||||
```
|
||||
register, login
|
||||
create_thought, delete_thought, edit_thought
|
||||
create_reply, delete_reply
|
||||
like_thought, unlike_thought
|
||||
boost_thought, unboost_thought
|
||||
follow_user, unfollow_user, accept_follow, reject_follow
|
||||
block_user, unblock_user
|
||||
update_profile, update_top_friends
|
||||
create_api_key, delete_api_key
|
||||
handle_inbox ← processes incoming AP activities from remote instances
|
||||
```
|
||||
|
||||
**Queries** (read-only, no events):
|
||||
```
|
||||
get_thought, get_thread ← thought + its reply tree
|
||||
get_home_feed ← thoughts from followed users (local + remote)
|
||||
get_public_feed ← all local public thoughts
|
||||
get_user_feed ← one user's public thoughts
|
||||
get_profile, get_top_friends
|
||||
get_followers, get_following
|
||||
list_api_keys
|
||||
search
|
||||
get_by_tag
|
||||
get_notifications
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Federation & ActivityPub
|
||||
|
||||
`activitypub-base/` (copied verbatim from movies-diary) handles: HTTP signatures, WebFinger, NodeInfo, generic actor/inbox/outbox/followers HTTP handlers, remote actor fetching.
|
||||
|
||||
`activitypub/` wires `activitypub-base` to the thoughts domain.
|
||||
|
||||
### Outbound (worker: domain event → AP activity → remote inboxes)
|
||||
|
||||
| Domain Event | AP Activity | Destination |
|
||||
|------------------|---------------------|--------------------------|
|
||||
| ThoughtCreated | Create(Note) | followers' inboxes |
|
||||
| ThoughtDeleted | Delete(Note) | followers' inboxes |
|
||||
| ThoughtUpdated | Update(Note) | followers' inboxes |
|
||||
| LikeAdded | Like | thought author's inbox |
|
||||
| LikeRemoved | Undo(Like) | thought author's inbox |
|
||||
| BoostAdded | Announce | followers' inboxes |
|
||||
| BoostRemoved | Undo(Announce) | followers' inboxes |
|
||||
| FollowRequested | Follow | target's inbox |
|
||||
| FollowAccepted | Accept(Follow) | requester's inbox |
|
||||
| FollowRejected | Reject(Follow) | requester's inbox |
|
||||
| Unfollowed | Undo(Follow) | target's inbox |
|
||||
| UserBlocked | Block | blocked user's inbox |
|
||||
|
||||
### Inbound (`handle_inbox` use case)
|
||||
|
||||
| Incoming Activity | Use Case invoked |
|
||||
|-------------------|----------------------------|
|
||||
| Create(Note) | create_thought (remote) |
|
||||
| Delete | delete_thought (remote) |
|
||||
| Update(Note) | edit_thought (remote) |
|
||||
| Like | like_thought (remote) |
|
||||
| Undo(Like) | unlike_thought (remote) |
|
||||
| Announce | boost_thought (remote) |
|
||||
| Undo(Announce) | unboost_thought (remote) |
|
||||
| Follow | follow_user → auto-accept (public accounts) / pending (locked accounts) |
|
||||
| Accept(Follow) | accept_follow |
|
||||
| Reject(Follow) | reject_follow |
|
||||
| Undo(Follow) | unfollow_user |
|
||||
| Block | block_user (remote) |
|
||||
|
||||
### AP Endpoints (in presentation/)
|
||||
|
||||
```
|
||||
GET /.well-known/webfinger
|
||||
GET /.well-known/nodeinfo
|
||||
GET /nodeinfo/2.0
|
||||
GET /users/:username ← Actor object
|
||||
GET /users/:username/inbox
|
||||
POST /users/:username/inbox ← receives remote activities
|
||||
GET /users/:username/outbox
|
||||
GET /users/:username/followers
|
||||
GET /users/:username/following
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema & Migration Strategy
|
||||
|
||||
### Remote thought caching
|
||||
|
||||
`likes` and `boosts` reference `thought_id UUID REFERENCES thoughts(id)`. When a local user likes or boosts a remote thought, the remote Note is first fetched and cached as a row in `thoughts` with `local = false`. This keeps referential integrity and allows rendering liked/boosted remote content without additional AP lookups.
|
||||
|
||||
### Migration approach
|
||||
|
||||
sqlx `migrations/` in `adapters/postgres/`. First migration recreates existing schema in sqlx format (matching production exactly, preserving all UUIDs). Subsequent migrations are additive only — no destructive changes.
|
||||
|
||||
### Additive changes to existing tables
|
||||
|
||||
```sql
|
||||
-- users: federation
|
||||
ALTER TABLE users ADD COLUMN ap_id TEXT UNIQUE;
|
||||
ALTER TABLE users ADD COLUMN inbox_url TEXT;
|
||||
ALTER TABLE users ADD COLUMN public_key TEXT;
|
||||
ALTER TABLE users ADD COLUMN private_key TEXT; -- NULL for remote users
|
||||
ALTER TABLE users ADD COLUMN local BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- thoughts: replies + AP + visibility
|
||||
ALTER TABLE thoughts ADD COLUMN in_reply_to_id UUID REFERENCES thoughts(id);
|
||||
ALTER TABLE thoughts ADD COLUMN in_reply_to_url TEXT; -- remote parent
|
||||
ALTER TABLE thoughts ADD COLUMN ap_id TEXT UNIQUE;
|
||||
ALTER TABLE thoughts ADD COLUMN visibility TEXT NOT NULL DEFAULT 'public';
|
||||
ALTER TABLE thoughts ADD COLUMN content_warning TEXT;
|
||||
ALTER TABLE thoughts ADD COLUMN sensitive BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE thoughts ADD COLUMN local BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE thoughts ADD COLUMN updated_at TIMESTAMPTZ;
|
||||
|
||||
-- follows: pending state + AP id
|
||||
ALTER TABLE follows ADD COLUMN state TEXT NOT NULL DEFAULT 'accepted';
|
||||
ALTER TABLE follows ADD COLUMN ap_id TEXT;
|
||||
ALTER TABLE follows ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
||||
```
|
||||
|
||||
### New tables
|
||||
|
||||
```sql
|
||||
CREATE TABLE likes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
thought_id UUID NOT NULL REFERENCES thoughts(id),
|
||||
ap_id TEXT UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (user_id, thought_id)
|
||||
);
|
||||
|
||||
CREATE TABLE boosts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
thought_id UUID NOT NULL REFERENCES thoughts(id),
|
||||
ap_id TEXT UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (user_id, thought_id)
|
||||
);
|
||||
|
||||
CREATE TABLE blocks (
|
||||
blocker_id UUID NOT NULL REFERENCES users(id),
|
||||
blocked_id UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (blocker_id, blocked_id)
|
||||
);
|
||||
|
||||
CREATE TABLE remote_actors (
|
||||
url TEXT PRIMARY KEY,
|
||||
handle TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
inbox_url TEXT NOT NULL,
|
||||
shared_inbox_url TEXT,
|
||||
public_key TEXT NOT NULL,
|
||||
last_fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE notifications (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
type TEXT NOT NULL, -- 'like','boost','follow','mention','reply'
|
||||
from_user_id UUID REFERENCES users(id),
|
||||
thought_id UUID REFERENCES thoughts(id),
|
||||
read BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Event System & Worker
|
||||
|
||||
### event-payload/
|
||||
Serializable wire types for NATS. Mirror of domain events with all fields as primitives (UUIDs as strings). `serde::Serialize/Deserialize`. No domain dependency.
|
||||
|
||||
### event-publisher/
|
||||
Receives `DomainEvent`, serializes to event-payload, routes to NATS subject (e.g. `thoughts.created`, `likes.added`). Implements domain's `EventPublisher` trait.
|
||||
|
||||
### nats/
|
||||
Wraps `async-nats`. Implements `EventPublisher` (publish to subject) and `EventConsumer` (subscribe, yields `EventEnvelope` stream with ack/nack handles).
|
||||
|
||||
### worker/ (binary)
|
||||
```
|
||||
EventConsumer::consume()
|
||||
→ deserialize EventEnvelope
|
||||
→ match event type → dispatch to EventHandler impl
|
||||
→ ack on success, nack on failure (NATS redelivers)
|
||||
|
||||
Handlers:
|
||||
FederationHandler ← domain events → AP activities → remote inboxes
|
||||
NotificationHandler ← writes notifications on like/boost/follow/mention/reply
|
||||
SearchIndexHandler ← indexes/removes documents on create/delete
|
||||
```
|
||||
|
||||
Handlers are plain structs taking `Arc<dyn Port>` — no NATS coupling inside them. Worker `main.rs` wires everything together.
|
||||
@@ -1,213 +0,0 @@
|
||||
# Remote Actor Connections (Followers/Following) Design
|
||||
|
||||
Display a remote actor's followers and following lists in the thoughts UI, with worker-backed caching and concurrent AP profile resolution.
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. User opens the Followers or Following tab on a remote actor profile
|
||||
2. Frontend calls `GET /federation/actors/{handle}/followers-list?page=1`
|
||||
3. Backend returns cached data immediately (may be empty on first visit)
|
||||
4. If cache is empty OR older than 1 hour: publish `FetchActorConnections` event fire-and-forget
|
||||
5. Worker receives event → fetches remote collection page → concurrently resolves each actor URL to a profile → stores results
|
||||
6. Next visit / tab re-open shows populated data
|
||||
|
||||
## Domain Changes
|
||||
|
||||
### New models (`domain/src/models/`)
|
||||
|
||||
**`connection_type.rs`**:
|
||||
```rust
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ConnectionType {
|
||||
Followers,
|
||||
Following,
|
||||
}
|
||||
|
||||
impl ConnectionType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self { Self::Followers => "followers", Self::Following => "following" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`actor_connection_summary.rs`**:
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ActorConnectionSummary {
|
||||
pub url: String, // AP URL of the connected actor
|
||||
pub handle: String,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
### New `DomainEvent` variant (`domain/src/events.rs`)
|
||||
|
||||
```rust
|
||||
FetchActorConnections {
|
||||
actor_ap_url: String,
|
||||
collection_url: String,
|
||||
connection_type: String, // "followers" | "following"
|
||||
page: u32,
|
||||
},
|
||||
```
|
||||
|
||||
### New port (`domain/src/ports.rs`)
|
||||
|
||||
```rust
|
||||
pub trait RemoteActorConnectionRepository: Send + Sync {
|
||||
async fn upsert_connections(
|
||||
&self,
|
||||
actor_url: &str,
|
||||
connection_type: &str,
|
||||
page: u32,
|
||||
actors: &[ActorConnectionSummary],
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
async fn list_connections(
|
||||
&self,
|
||||
actor_url: &str,
|
||||
connection_type: &str,
|
||||
page: u32,
|
||||
) -> Result<Vec<ActorConnectionSummary>, DomainError>;
|
||||
|
||||
async fn connection_page_age(
|
||||
&self,
|
||||
actor_url: &str,
|
||||
connection_type: &str,
|
||||
page: u32,
|
||||
) -> Result<Option<chrono::DateTime<chrono::Utc>>, DomainError>;
|
||||
}
|
||||
```
|
||||
|
||||
### New `FederationActionPort` method
|
||||
|
||||
```rust
|
||||
async fn resolve_actor_profiles(
|
||||
&self,
|
||||
urls: Vec<String>,
|
||||
) -> Vec<ActorConnectionSummary>;
|
||||
```
|
||||
|
||||
Returns only successful resolutions. Per-actor timeout: 5 seconds. Concurrent. No error propagation — failures are silently skipped (warn logged).
|
||||
|
||||
## Storage
|
||||
|
||||
### Migration: `006_remote_actor_connections.sql`
|
||||
|
||||
```sql
|
||||
CREATE TABLE remote_actor_connections (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
actor_url TEXT NOT NULL,
|
||||
connection_type TEXT NOT NULL,
|
||||
page INT NOT NULL,
|
||||
connected_actor_url TEXT NOT NULL,
|
||||
connected_handle TEXT NOT NULL,
|
||||
connected_display_name TEXT,
|
||||
connected_avatar_url TEXT,
|
||||
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(actor_url, connection_type, page, connected_actor_url)
|
||||
);
|
||||
CREATE INDEX ON remote_actor_connections(actor_url, connection_type, page, fetched_at);
|
||||
```
|
||||
|
||||
### `PgRemoteActorConnectionRepository`
|
||||
|
||||
- `upsert_connections`: `INSERT ... ON CONFLICT DO UPDATE SET connected_handle=EXCLUDED.connected_handle, connected_display_name=EXCLUDED.connected_display_name, connected_avatar_url=EXCLUDED.connected_avatar_url, fetched_at=NOW()`
|
||||
- `list_connections`: `SELECT * WHERE actor_url=$1 AND connection_type=$2 AND page=$3 ORDER BY connected_handle`
|
||||
- `connection_page_age`: `SELECT MAX(fetched_at) WHERE actor_url=$1 AND connection_type=$2 AND page=$3`
|
||||
|
||||
## activitypub-base: `resolve_actor_profiles`
|
||||
|
||||
`ActivityPubService` implements `FederationActionPort::resolve_actor_profiles`:
|
||||
|
||||
1. For each URL: spawn `tokio::time::timeout(5s, fetch_actor_profile(url))`
|
||||
2. `fetch_actor_profile`: `GET {url}` with `Accept: application/activity+json` → parse `preferred_username`, `name`, `icon.url`, `id`
|
||||
3. Collect `Ok` results → return as `Vec<ActorConnectionSummary>`
|
||||
4. Failed/timed-out actors: `tracing::warn!` and skip
|
||||
|
||||
## event-payload
|
||||
|
||||
Add `FetchActorConnections { actor_ap_url, collection_url, connection_type, page }` to `EventPayload` — subject: `"federation.fetch_actor_connections"`. Add to `From<&DomainEvent>`, `TryFrom<EventPayload>`, and uniqueness test.
|
||||
|
||||
## Worker
|
||||
|
||||
`FederationEventService` gains `remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>`.
|
||||
|
||||
Handler for `FetchActorConnections { actor_ap_url, collection_url, connection_type, page }`:
|
||||
|
||||
1. Fetch `collection_url` (as AP JSON) → extract `orderedItems` array as Vec of URL strings
|
||||
2. If empty: return Ok(()) — nothing to store
|
||||
3. `federation_action.resolve_actor_profiles(urls).await` — concurrent, partial success OK
|
||||
4. `remote_actor_connections.upsert_connections(actor_ap_url, connection_type, page, &results).await`
|
||||
5. Log: `tracing::info!(count = results.len(), "actor connections cached")`
|
||||
|
||||
Wire `remote_actor_connections` in `worker/src/factory.rs`.
|
||||
|
||||
## AppState + Bootstrap
|
||||
|
||||
Add `remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>` to `AppState`. Wire `PgRemoteActorConnectionRepository` in `bootstrap/src/factory.rs`.
|
||||
|
||||
## REST Endpoints
|
||||
|
||||
**`GET /federation/actors/{handle}/followers-list?page=1`**
|
||||
|
||||
```
|
||||
1. lookup_actor(handle) → get actor_ap_url + followers_url
|
||||
2. list_connections(actor_ap_url, "followers", page) → cached items
|
||||
3. connection_page_age(...) → if None or > 1 hour: publish FetchActorConnections (fire-and-forget)
|
||||
4. Return { items: [...], page, has_more: items.len() == PAGE_SIZE }
|
||||
```
|
||||
|
||||
`PAGE_SIZE = 20`. `has_more` tells the frontend whether to show a "next" button.
|
||||
|
||||
**`GET /federation/actors/{handle}/following-list?page=1`** — identical, uses `following_url` and `"following"`.
|
||||
|
||||
Response item shape (reuses `RemoteActorResponse` minus `bio`/`banner`/`attachment`/`outbox_url`):
|
||||
```json
|
||||
{ "handle": "...", "displayName": "...", "avatarUrl": "...", "url": "..." }
|
||||
```
|
||||
|
||||
Define as a new `ActorConnectionResponse` in api-types.
|
||||
|
||||
Mount both routes in `routes.rs`. Add new handler file `federation_actors.rs` (already exists — add to it).
|
||||
|
||||
## Frontend
|
||||
|
||||
### `lib/api.ts`
|
||||
|
||||
```typescript
|
||||
export const ActorConnectionSchema = z.object({
|
||||
handle: z.string(),
|
||||
displayName: z.string().nullable(),
|
||||
avatarUrl: z.string().nullable(),
|
||||
url: z.string(),
|
||||
});
|
||||
export type ActorConnection = z.infer<typeof ActorConnectionSchema>;
|
||||
|
||||
const ConnectionPageSchema = z.object({
|
||||
items: z.array(ActorConnectionSchema),
|
||||
page: z.number(),
|
||||
hasMore: z.boolean(),
|
||||
});
|
||||
|
||||
export const getActorFollowers = (handle, page, token) =>
|
||||
apiFetch(`/federation/actors/${encodeURIComponent(handle)}/followers-list?page=${page}`, {}, ConnectionPageSchema, token);
|
||||
|
||||
export const getActorFollowing = (handle, page, token) =>
|
||||
apiFetch(`/federation/actors/${encodeURIComponent(handle)}/following-list?page=${page}`, {}, ConnectionPageSchema, token);
|
||||
```
|
||||
|
||||
### `RemoteUserProfile` changes
|
||||
|
||||
Replace the plain "Followers / Following" link section with two client-side tabs. Each tab:
|
||||
- Shows a list of `RemoteUserCard` components (reuse existing)
|
||||
- "Load more" button if `hasMore`
|
||||
- Empty state: "Loading — check back soon."
|
||||
- Tab is lazy: only fetches when first opened (not on profile load)
|
||||
|
||||
Use the existing `RemoteUserCard` component — it already handles follow button and linking.
|
||||
|
||||
### `remote-user-profile.tsx` note
|
||||
|
||||
The component is already a client component (`"use client"`), so React state for tab selection and paginated data works fine. Each tab fetches via `getActorFollowers`/`getActorFollowing` when first activated.
|
||||
Reference in New Issue
Block a user