Compare commits

...

10 Commits

61 changed files with 5464 additions and 0 deletions

48
Cargo.toml Normal file
View File

@@ -0,0 +1,48 @@
[workspace]
members = [
"crates/domain",
"crates/application",
"crates/api-types",
"crates/presentation",
"crates/worker",
"crates/adapters/postgres",
"crates/adapters/postgres-search",
"crates/adapters/postgres-federation",
"crates/adapters/activitypub-base",
"crates/adapters/activitypub",
"crates/adapters/auth",
"crates/adapters/nats",
"crates/adapters/event-payload",
"crates/adapters/event-publisher",
]
resolver = "2"
[workspace.dependencies]
tokio = { version = "1.0", features = ["macros", "net", "rt", "rt-multi-thread", "sync", "time"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
thiserror = "2.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
async-trait = "0.1"
uuid = { version = "1.0", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "macros"] }
axum = { version = "0.8", features = ["macros"] }
tower-http = { version = "0.6", features = ["cors", "trace"] }
futures = "0.3"
dotenvy = "0.15"
domain = { path = "crates/domain" }
application = { path = "crates/application" }
api-types = { path = "crates/api-types" }
postgres = { path = "crates/adapters/postgres" }
postgres-search = { path = "crates/adapters/postgres-search" }
postgres-federation = { path = "crates/adapters/postgres-federation" }
activitypub-base = { path = "crates/adapters/activitypub-base" }
activitypub = { path = "crates/adapters/activitypub" }
auth = { path = "crates/adapters/auth" }
nats = { path = "crates/adapters/nats" }
event-payload = { path = "crates/adapters/event-payload" }
event-publisher = { path = "crates/adapters/event-publisher" }

View File

@@ -0,0 +1,4 @@
[package]
name = "activitypub-base"
version = "0.1.0"
edition = "2021"

View File

@@ -0,0 +1,4 @@
[package]
name = "activitypub"
version = "0.1.0"
edition = "2021"

View File

View File

@@ -0,0 +1,14 @@
[package]
name = "auth"
version = "0.1.0"
edition = "2021"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
tokio = { workspace = true }
jsonwebtoken = "9"
argon2 = "0.5"

View File

View File

@@ -0,0 +1,4 @@
[package]
name = "event-payload"
version = "0.1.0"
edition = "2021"

View File

View File

@@ -0,0 +1,4 @@
[package]
name = "event-publisher"
version = "0.1.0"
edition = "2021"

View File

@@ -0,0 +1,4 @@
[package]
name = "nats"
version = "0.1.0"
edition = "2021"

View File

View File

@@ -0,0 +1,4 @@
[package]
name = "postgres-federation"
version = "0.1.0"
edition = "2021"

View File

@@ -0,0 +1,4 @@
[package]
name = "postgres-search"
version = "0.1.0"
edition = "2021"

View File

@@ -0,0 +1,17 @@
[package]
name = "postgres"
version = "0.1.0"
edition = "2021"
[dependencies]
domain = { workspace = true }
sqlx = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
async-trait = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["full"] }
sqlx = { workspace = true, features = ["migrate"] }

View File

@@ -0,0 +1,55 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(32) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name VARCHAR(50),
bio VARCHAR(160),
avatar_url TEXT,
header_url TEXT,
custom_css TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS thoughts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content VARCHAR(128) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS follows (
follower_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
following_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (follower_id, following_id)
);
CREATE TABLE IF NOT EXISTS top_friends (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
friend_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
position SMALLINT NOT NULL,
PRIMARY KEY (user_id, friend_id),
UNIQUE (user_id, position)
);
CREATE TABLE IF NOT EXISTS tags (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS thought_tags (
thought_id UUID NOT NULL REFERENCES thoughts(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (thought_id, tag_id)
);
CREATE TABLE IF NOT EXISTS api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key_hash TEXT NOT NULL UNIQUE,
name VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@@ -0,0 +1,21 @@
ALTER TABLE users
ADD COLUMN IF NOT EXISTS ap_id TEXT UNIQUE,
ADD COLUMN IF NOT EXISTS inbox_url TEXT,
ADD COLUMN IF NOT EXISTS public_key TEXT,
ADD COLUMN IF NOT EXISTS private_key TEXT,
ADD COLUMN IF NOT EXISTS local BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE thoughts
ADD COLUMN IF NOT EXISTS in_reply_to_id UUID REFERENCES thoughts(id),
ADD COLUMN IF NOT EXISTS in_reply_to_url TEXT,
ADD COLUMN IF NOT EXISTS ap_id TEXT UNIQUE,
ADD COLUMN IF NOT EXISTS visibility TEXT NOT NULL DEFAULT 'public',
ADD COLUMN IF NOT EXISTS content_warning TEXT,
ADD COLUMN IF NOT EXISTS sensitive BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS local BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ;
ALTER TABLE follows
ADD COLUMN IF NOT EXISTS state TEXT NOT NULL DEFAULT 'accepted',
ADD COLUMN IF NOT EXISTS ap_id TEXT,
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();

View File

@@ -0,0 +1,49 @@
CREATE TABLE IF NOT EXISTS likes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
thought_id UUID NOT NULL REFERENCES thoughts(id) ON DELETE CASCADE,
ap_id TEXT UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, thought_id)
);
CREATE TABLE IF NOT EXISTS boosts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
thought_id UUID NOT NULL REFERENCES thoughts(id) ON DELETE CASCADE,
ap_id TEXT UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, thought_id)
);
CREATE TABLE IF NOT EXISTS blocks (
blocker_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
blocked_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (blocker_id, blocked_id)
);
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
from_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
thought_id UUID REFERENCES thoughts(id) ON DELETE CASCADE,
read BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_thoughts_user_id ON thoughts(user_id);
CREATE INDEX IF NOT EXISTS idx_thoughts_created_at ON thoughts(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_follows_following_id ON follows(following_id);
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id, read);

View File

@@ -0,0 +1,2 @@
pub struct PgApiKeyRepository { _pool: sqlx::PgPool }
impl PgApiKeyRepository { pub fn new(pool: sqlx::PgPool) -> Self { Self { _pool: pool } } }

View File

@@ -0,0 +1,2 @@
pub struct PgBlockRepository { _pool: sqlx::PgPool }
impl PgBlockRepository { pub fn new(pool: sqlx::PgPool) -> Self { Self { _pool: pool } } }

View File

@@ -0,0 +1,2 @@
pub struct PgBoostRepository { _pool: sqlx::PgPool }
impl PgBoostRepository { pub fn new(pool: sqlx::PgPool) -> Self { Self { _pool: pool } } }

View File

@@ -0,0 +1,2 @@
pub struct PgFeedRepository { _pool: sqlx::PgPool }
impl PgFeedRepository { pub fn new(pool: sqlx::PgPool) -> Self { Self { _pool: pool } } }

View File

@@ -0,0 +1,2 @@
pub struct PgFollowRepository { _pool: sqlx::PgPool }
impl PgFollowRepository { pub fn new(pool: sqlx::PgPool) -> Self { Self { _pool: pool } } }

View File

@@ -0,0 +1,12 @@
pub mod api_key;
pub mod block;
pub mod boost;
pub mod feed;
pub mod follow;
pub mod like;
pub mod notification;
pub mod remote_actor;
pub mod tag;
pub mod thought;
pub mod top_friend;
pub mod user;

View File

@@ -0,0 +1,2 @@
pub struct PgLikeRepository { _pool: sqlx::PgPool }
impl PgLikeRepository { pub fn new(pool: sqlx::PgPool) -> Self { Self { _pool: pool } } }

View File

@@ -0,0 +1,2 @@
pub struct PgNotificationRepository { _pool: sqlx::PgPool }
impl PgNotificationRepository { pub fn new(pool: sqlx::PgPool) -> Self { Self { _pool: pool } } }

View File

@@ -0,0 +1,2 @@
pub struct PgRemoteActorRepository { _pool: sqlx::PgPool }
impl PgRemoteActorRepository { pub fn new(pool: sqlx::PgPool) -> Self { Self { _pool: pool } } }

View File

@@ -0,0 +1,2 @@
pub struct PgTagRepository { _pool: sqlx::PgPool }
impl PgTagRepository { pub fn new(pool: sqlx::PgPool) -> Self { Self { _pool: pool } } }

View File

@@ -0,0 +1,236 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use domain::{
errors::DomainError,
models::{
feed::{FeedEntry, PageParams, Paginated},
thought::{Thought, Visibility},
user::User,
},
ports::ThoughtRepository,
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
};
pub struct PgThoughtRepository { pool: PgPool }
impl PgThoughtRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
#[derive(sqlx::FromRow)]
pub(crate) struct ThoughtRow {
pub id: uuid::Uuid,
pub user_id: uuid::Uuid,
pub content: String,
pub in_reply_to_id: Option<uuid::Uuid>,
pub in_reply_to_url: Option<String>,
pub ap_id: Option<String>,
pub visibility: String,
pub content_warning: Option<String>,
pub sensitive: bool,
pub local: bool,
pub created_at: DateTime<Utc>,
pub updated_at: Option<DateTime<Utc>>,
}
impl From<ThoughtRow> for Thought {
fn from(r: ThoughtRow) -> Self {
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: r.in_reply_to_url,
ap_id: r.ap_id,
visibility: Visibility::from_str(&r.visibility),
content_warning: r.content_warning,
sensitive: r.sensitive,
local: r.local,
created_at: r.created_at,
updated_at: r.updated_at,
}
}
}
const THOUGHT_SELECT: &str =
"SELECT id,user_id,content,in_reply_to_id,in_reply_to_url,ap_id,visibility,content_warning,sensitive,local,created_at,updated_at FROM thoughts";
#[async_trait]
impl ThoughtRepository for PgThoughtRepository {
async fn save(&self, t: &Thought) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO thoughts(id,user_id,content,in_reply_to_id,in_reply_to_url,ap_id,visibility,content_warning,sensitive,local,created_at)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
ON CONFLICT(id) DO UPDATE SET content=EXCLUDED.content,updated_at=NOW()"
)
.bind(t.id.as_uuid())
.bind(t.user_id.as_uuid())
.bind(t.content.as_str())
.bind(t.in_reply_to_id.as_ref().map(|x| x.as_uuid()))
.bind(&t.in_reply_to_url)
.bind(&t.ap_id)
.bind(t.visibility.as_str())
.bind(&t.content_warning)
.bind(t.sensitive)
.bind(t.local)
.bind(t.created_at)
.execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
.map(|_| ())
}
async fn find_by_id(&self, id: &ThoughtId) -> Result<Option<Thought>, DomainError> {
sqlx::query_as::<_, ThoughtRow>(&format!("{THOUGHT_SELECT} WHERE id=$1"))
.bind(id.as_uuid())
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
.map(|o| o.map(Thought::from))
}
async fn delete(&self, id: &ThoughtId, user_id: &UserId) -> Result<(), DomainError> {
let r = sqlx::query("DELETE FROM thoughts WHERE id=$1 AND user_id=$2")
.bind(id.as_uuid())
.bind(user_id.as_uuid())
.execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
if r.rows_affected() == 0 { return Err(DomainError::NotFound); }
Ok(())
}
async fn update_content(&self, id: &ThoughtId, content: &Content) -> Result<(), DomainError> {
sqlx::query("UPDATE thoughts SET content=$2,updated_at=NOW() WHERE id=$1")
.bind(id.as_uuid())
.bind(content.as_str())
.execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
.map(|_| ())
}
async fn get_thread(&self, id: &ThoughtId) -> Result<Vec<Thought>, DomainError> {
sqlx::query_as::<_, ThoughtRow>(
&format!("{THOUGHT_SELECT} WHERE id=$1 OR in_reply_to_id=$1 ORDER BY created_at ASC")
)
.bind(id.as_uuid())
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
.map(|rows| rows.into_iter().map(Thought::from).collect())
}
async fn list_by_user(&self, user_id: &UserId, page: &PageParams) -> Result<Paginated<FeedEntry>, DomainError> {
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts WHERE user_id=$1")
.bind(user_id.as_uuid())
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
let rows = sqlx::query_as::<_, ThoughtRow>(
&format!("{THOUGHT_SELECT} WHERE user_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3")
)
.bind(user_id.as_uuid())
.bind(page.limit())
.bind(page.offset())
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
let author = sqlx::query_as::<_, crate::user::UserRow>(
"SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,ap_id,inbox_url,public_key,private_key,created_at,updated_at FROM users WHERE id=$1"
)
.bind(user_id.as_uuid())
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?
.ok_or(DomainError::NotFound)?;
let author = User::from(author);
let items = rows.into_iter().map(|r| {
let thought = Thought::from(r);
FeedEntry {
thought,
author: author.clone(),
like_count: 0,
boost_count: 0,
reply_count: 0,
liked_by_viewer: false,
boosted_by_viewer: false,
}
}).collect();
Ok(Paginated { items, total, page: page.page, per_page: page.per_page })
}
}
#[cfg(test)]
mod tests {
use super::*;
use domain::{models::{thought::{Thought, Visibility}, user::User}, value_objects::*};
use crate::user::PgUserRepository;
use domain::ports::UserRepository;
async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User {
let repo = PgUserRepository::new(pool.clone());
let u = User::new_local(
UserId::new(),
Username::new(username).unwrap(),
Email::new(email).unwrap(),
PasswordHash("h".into()),
);
repo.save(&u).await.unwrap();
u
}
#[sqlx::test(migrations = "./migrations")]
async fn save_and_find_thought(pool: sqlx::PgPool) {
let user = seed_user(&pool, "alice", "alice@ex.com").await;
let repo = PgThoughtRepository::new(pool);
let t = Thought::new_local(
ThoughtId::new(),
user.id.clone(),
Content::new_local("hello world").unwrap(),
None,
Visibility::Public,
None,
false,
);
repo.save(&t).await.unwrap();
let found = repo.find_by_id(&t.id).await.unwrap().unwrap();
assert_eq!(found.content.as_str(), "hello world");
assert!(found.local);
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_thought(pool: sqlx::PgPool) {
let user = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgThoughtRepository::new(pool);
let t = Thought::new_local(ThoughtId::new(), user.id.clone(), Content::new_local("bye").unwrap(), None, Visibility::Public, None, false);
repo.save(&t).await.unwrap();
repo.delete(&t.id, &user.id).await.unwrap();
assert!(repo.find_by_id(&t.id).await.unwrap().is_none());
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_wrong_owner_returns_not_found(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgThoughtRepository::new(pool);
let t = Thought::new_local(ThoughtId::new(), alice.id.clone(), Content::new_local("secret").unwrap(), None, Visibility::Public, None, false);
repo.save(&t).await.unwrap();
let err = repo.delete(&t.id, &bob.id).await.unwrap_err();
assert!(matches!(err, DomainError::NotFound));
}
#[sqlx::test(migrations = "./migrations")]
async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) {
let user = seed_user(&pool, "charlie", "charlie@ex.com").await;
let repo = PgThoughtRepository::new(pool);
let root = Thought::new_local(ThoughtId::new(), user.id.clone(), Content::new_local("root").unwrap(), None, Visibility::Public, None, false);
let reply = Thought::new_local(ThoughtId::new(), user.id.clone(), Content::new_local("reply").unwrap(), Some(root.id.clone()), Visibility::Public, None, false);
repo.save(&root).await.unwrap();
repo.save(&reply).await.unwrap();
let thread = repo.get_thread(&root.id).await.unwrap();
assert_eq!(thread.len(), 2);
}
}

View File

@@ -0,0 +1,2 @@
pub struct PgTopFriendRepository { _pool: sqlx::PgPool }
impl PgTopFriendRepository { pub fn new(pool: sqlx::PgPool) -> Self { Self { _pool: pool } } }

View File

@@ -0,0 +1,237 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use domain::{
errors::DomainError,
models::{feed::UserSummary, user::User},
ports::UserRepository,
value_objects::{Email, PasswordHash, UserId, Username},
};
pub struct PgUserRepository { pool: PgPool }
impl PgUserRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
#[derive(sqlx::FromRow)]
pub(crate) struct UserRow {
pub id: uuid::Uuid,
pub username: String,
pub email: String,
pub password_hash: String,
pub display_name: Option<String>,
pub bio: Option<String>,
pub avatar_url: Option<String>,
pub header_url: Option<String>,
pub custom_css: Option<String>,
pub local: bool,
pub ap_id: Option<String>,
pub inbox_url: Option<String>,
pub public_key: Option<String>,
pub private_key: Option<String>,
pub created_at: DateTime<Utc>,
pub 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";
#[async_trait]
impl UserRepository for PgUserRepository {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
sqlx::query_as::<_, UserRow>(&format!("{USER_SELECT} WHERE id=$1"))
.bind(id.as_uuid())
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
.map(|o| o.map(User::from))
}
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
sqlx::query_as::<_, UserRow>(&format!("{USER_SELECT} WHERE username=$1"))
.bind(username.as_str())
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
.map(|o| o.map(User::from))
}
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
sqlx::query_as::<_, UserRow>(&format!("{USER_SELECT} WHERE email=$1"))
.bind(email.as_str())
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
.map(|o| o.map(User::from))
}
async fn save(&self, user: &User) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO users (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)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
ON CONFLICT(id) DO UPDATE SET
username=EXCLUDED.username, email=EXCLUDED.email,
password_hash=EXCLUDED.password_hash, display_name=EXCLUDED.display_name,
bio=EXCLUDED.bio, avatar_url=EXCLUDED.avatar_url,
header_url=EXCLUDED.header_url, custom_css=EXCLUDED.custom_css,
local=EXCLUDED.local, ap_id=EXCLUDED.ap_id, inbox_url=EXCLUDED.inbox_url,
public_key=EXCLUDED.public_key, private_key=EXCLUDED.private_key,
updated_at=NOW()"
)
.bind(user.id.as_uuid())
.bind(user.username.as_str())
.bind(user.email.as_str())
.bind(&user.password_hash.0)
.bind(&user.display_name)
.bind(&user.bio)
.bind(&user.avatar_url)
.bind(&user.header_url)
.bind(&user.custom_css)
.bind(user.local)
.bind(&user.ap_id)
.bind(&user.inbox_url)
.bind(&user.public_key)
.bind(&user.private_key)
.bind(user.created_at)
.bind(user.updated_at)
.execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
.map(|_| ())
}
async fn update_profile(&self, user_id: &UserId, display_name: Option<String>, bio: Option<String>, avatar_url: Option<String>, header_url: Option<String>, custom_css: Option<String>) -> Result<(), DomainError> {
sqlx::query(
"UPDATE users SET display_name=$2,bio=$3,avatar_url=$4,header_url=$5,custom_css=$6,updated_at=NOW() WHERE id=$1"
)
.bind(user_id.as_uuid())
.bind(display_name)
.bind(bio)
.bind(avatar_url)
.bind(header_url)
.bind(custom_css)
.execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
.map(|_| ())
}
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
#[derive(sqlx::FromRow)]
struct Row {
id: uuid::Uuid,
username: String,
display_name: Option<String>,
avatar_url: Option<String>,
bio: Option<String>,
thought_count: i64,
follower_count: i64,
following_count: i64,
}
let rows = sqlx::query_as::<_, Row>(
"SELECT u.id, u.username, u.display_name, u.avatar_url, u.bio,
COUNT(DISTINCT t.id) AS thought_count,
COUNT(DISTINCT f1.follower_id) AS follower_count,
COUNT(DISTINCT f2.following_id) AS following_count
FROM users u
LEFT JOIN thoughts t ON t.user_id=u.id AND t.local=true
LEFT JOIN follows f1 ON f1.following_id=u.id AND f1.state='accepted'
LEFT JOIN follows f2 ON f2.follower_id=u.id AND f2.state='accepted'
WHERE u.local=true
GROUP BY u.id
ORDER BY u.username"
)
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(rows.into_iter().map(|r| UserSummary {
id: UserId::from_uuid(r.id),
username: r.username,
display_name: r.display_name,
avatar_url: r.avatar_url,
bio: r.bio,
thought_count: r.thought_count,
follower_count: r.follower_count,
following_count: r.following_count,
}).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use domain::{models::user::User, value_objects::*};
#[sqlx::test(migrations = "./migrations")]
async fn save_and_find_by_id(pool: sqlx::PgPool) {
let repo = PgUserRepository::new(pool);
let user = User::new_local(
UserId::new(),
Username::new("alice").unwrap(),
Email::new("alice@ex.com").unwrap(),
PasswordHash("hash".into()),
);
repo.save(&user).await.unwrap();
let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
assert_eq!(found.username.as_str(), "alice");
assert_eq!(found.email.as_str(), "alice@ex.com");
}
#[sqlx::test(migrations = "./migrations")]
async fn find_by_username_returns_none_when_missing(pool: sqlx::PgPool) {
let repo = PgUserRepository::new(pool);
let result = repo.find_by_username(&Username::new("ghost").unwrap()).await.unwrap();
assert!(result.is_none());
}
#[sqlx::test(migrations = "./migrations")]
async fn find_by_email(pool: sqlx::PgPool) {
let repo = PgUserRepository::new(pool);
let user = User::new_local(
UserId::new(),
Username::new("bob").unwrap(),
Email::new("bob@ex.com").unwrap(),
PasswordHash("hash".into()),
);
repo.save(&user).await.unwrap();
let found = repo.find_by_email(&Email::new("bob@ex.com").unwrap()).await.unwrap();
assert!(found.is_some());
}
#[sqlx::test(migrations = "./migrations")]
async fn update_profile_changes_fields(pool: sqlx::PgPool) {
let repo = PgUserRepository::new(pool);
let user = User::new_local(
UserId::new(),
Username::new("charlie").unwrap(),
Email::new("charlie@ex.com").unwrap(),
PasswordHash("hash".into()),
);
repo.save(&user).await.unwrap();
repo.update_profile(&user.id, Some("Charlie".into()), Some("bio".into()), None, None, None).await.unwrap();
let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
assert_eq!(found.display_name.as_deref(), Some("Charlie"));
assert_eq!(found.bio.as_deref(), Some("bio"));
}
}

View File

@@ -0,0 +1,9 @@
[package]
name = "api-types"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }

View File

View File

@@ -0,0 +1,15 @@
[package]
name = "application"
version = "0.1.0"
edition = "2021"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["full"] }
domain = { workspace = true, features = ["test-helpers"] }

View File

18
crates/domain/Cargo.toml Normal file
View File

@@ -0,0 +1,18 @@
[package]
name = "domain"
version = "0.1.0"
edition = "2021"
[features]
test-helpers = []
[dependencies]
async-trait = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
serde = { workspace = true }
futures = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["full"] }

View File

@@ -0,0 +1,17 @@
use thiserror::Error;
#[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("internal error: {0}")]
Internal(String),
}

View File

@@ -0,0 +1,28 @@
use crate::value_objects::{UserId, ThoughtId, LikeId, BoostId};
#[derive(Debug, Clone)]
pub enum DomainEvent {
ThoughtCreated { thought_id: ThoughtId, user_id: UserId, in_reply_to_id: Option<ThoughtId> },
ThoughtDeleted { thought_id: ThoughtId, user_id: UserId },
ThoughtUpdated { thought_id: ThoughtId, user_id: UserId },
LikeAdded { like_id: LikeId, user_id: UserId, thought_id: ThoughtId },
LikeRemoved { user_id: UserId, thought_id: ThoughtId },
BoostAdded { boost_id: BoostId, user_id: UserId, thought_id: ThoughtId },
BoostRemoved { user_id: UserId, thought_id: ThoughtId },
FollowRequested { follower_id: UserId, following_id: UserId },
FollowAccepted { follower_id: UserId, following_id: UserId },
FollowRejected { follower_id: UserId, following_id: UserId },
Unfollowed { follower_id: UserId, following_id: UserId },
UserBlocked { blocker_id: UserId, blocked_id: UserId },
}
pub struct EventEnvelope {
pub event: DomainEvent,
pub ack: Box<dyn Fn() + Send + Sync>,
pub nack: Box<dyn Fn() + Send + Sync>,
}
impl std::fmt::Debug for EventEnvelope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EventEnvelope").field("event", &self.event).finish()
}
}

8
crates/domain/src/lib.rs Normal file
View File

@@ -0,0 +1,8 @@
pub mod errors;
pub mod events;
pub mod models;
pub mod ports;
pub mod value_objects;
#[cfg(any(test, feature = "test-helpers"))]
pub mod testing;

View File

@@ -0,0 +1,11 @@
use chrono::{DateTime, Utc};
use crate::value_objects::{ApiKeyId, UserId};
#[derive(Debug, Clone)]
pub struct ApiKey {
pub id: ApiKeyId,
pub user_id: UserId,
pub key_hash: String,
pub name: String,
pub created_at: DateTime<Utc>,
}

View File

@@ -0,0 +1,40 @@
use crate::models::{user::User, thought::Thought};
use crate::value_objects::UserId;
#[derive(Debug, Clone)]
pub struct UserSummary {
pub id: UserId,
pub username: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub bio: Option<String>,
pub thought_count: i64,
pub follower_count: i64,
pub following_count: i64,
}
#[derive(Debug, Clone)]
pub struct FeedEntry {
pub thought: Thought,
pub author: User,
pub like_count: i64,
pub boost_count: i64,
pub reply_count: i64,
pub liked_by_viewer: bool,
pub boosted_by_viewer: bool,
}
#[derive(Debug, Clone)]
pub struct PageParams { pub page: u64, pub per_page: u64 }
impl PageParams {
pub fn offset(&self) -> i64 { ((self.page.saturating_sub(1)) * self.per_page) as i64 }
pub fn limit(&self) -> i64 { self.per_page as i64 }
}
#[derive(Debug, Clone)]
pub struct Paginated<T> {
pub items: Vec<T>,
pub total: i64,
pub page: u64,
pub per_page: u64,
}

View File

@@ -0,0 +1,9 @@
pub mod api_key;
pub mod feed;
pub mod notification;
pub mod remote_actor;
pub mod social;
pub mod tag;
pub mod thought;
pub mod top_friend;
pub mod user;

View File

@@ -0,0 +1,24 @@
use chrono::{DateTime, Utc};
use crate::value_objects::{NotificationId, UserId, ThoughtId};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NotificationType { Like, Boost, Follow, Mention, Reply }
impl NotificationType {
pub fn from_str(s: &str) -> Self {
match s { "like" => Self::Like, "boost" => Self::Boost, "follow" => Self::Follow, "mention" => Self::Mention, _ => Self::Reply }
}
pub fn as_str(&self) -> &str {
match self { Self::Like => "like", Self::Boost => "boost", Self::Follow => "follow", Self::Mention => "mention", Self::Reply => "reply" }
}
}
#[derive(Debug, Clone)]
pub struct Notification {
pub id: NotificationId,
pub user_id: UserId,
pub notification_type: NotificationType,
pub from_user_id: Option<UserId>,
pub thought_id: Option<ThoughtId>,
pub read: bool,
pub created_at: DateTime<Utc>,
}

View File

@@ -0,0 +1,12 @@
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 last_fetched_at: DateTime<Utc>,
}

View File

@@ -0,0 +1,47 @@
use chrono::{DateTime, Utc};
use crate::value_objects::{UserId, ThoughtId, LikeId, BoostId};
#[derive(Debug, Clone)]
pub struct Like {
pub id: LikeId,
pub user_id: UserId,
pub thought_id: ThoughtId,
pub ap_id: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct Boost {
pub id: BoostId,
pub user_id: UserId,
pub thought_id: ThoughtId,
pub ap_id: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FollowState { Pending, Accepted, Rejected }
impl FollowState {
pub fn from_str(s: &str) -> Self {
match s { "pending" => Self::Pending, "rejected" => Self::Rejected, _ => Self::Accepted }
}
pub fn as_str(&self) -> &str {
match self { Self::Pending => "pending", Self::Accepted => "accepted", Self::Rejected => "rejected" }
}
}
#[derive(Debug, Clone)]
pub struct Follow {
pub follower_id: UserId,
pub following_id: UserId,
pub state: FollowState,
pub ap_id: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct Block {
pub blocker_id: UserId,
pub blocked_id: UserId,
pub created_at: DateTime<Utc>,
}

View File

@@ -0,0 +1,2 @@
#[derive(Debug, Clone)]
pub struct Tag { pub id: i32, pub name: String }

View File

@@ -0,0 +1,45 @@
use chrono::{DateTime, Utc};
use crate::value_objects::{ThoughtId, UserId, Content};
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Visibility {
Public, Followers, Unlisted, Direct,
}
impl Visibility {
pub fn from_str(s: &str) -> Self {
match s { "followers" => Self::Followers, "unlisted" => Self::Unlisted, "direct" => Self::Direct, _ => Self::Public }
}
pub fn as_str(&self) -> &str {
match self { Self::Public => "public", Self::Followers => "followers", Self::Unlisted => "unlisted", Self::Direct => "direct" }
}
}
#[derive(Debug, Clone)]
pub struct Thought {
pub id: ThoughtId,
pub user_id: UserId,
pub content: Content,
pub in_reply_to_id: Option<ThoughtId>,
pub in_reply_to_url: Option<String>,
pub ap_id: Option<String>,
pub visibility: Visibility,
pub content_warning: Option<String>,
pub sensitive: bool,
pub local: bool,
pub created_at: DateTime<Utc>,
pub updated_at: Option<DateTime<Utc>>,
}
impl Thought {
pub fn new_local(
id: ThoughtId, user_id: UserId, content: Content,
in_reply_to_id: Option<ThoughtId>, visibility: Visibility,
content_warning: Option<String>, sensitive: bool,
) -> Self {
Self {
id, user_id, content, in_reply_to_id, in_reply_to_url: None, ap_id: None,
visibility, content_warning, sensitive, local: true,
created_at: Utc::now(), updated_at: None,
}
}
}

View File

@@ -0,0 +1,4 @@
use crate::value_objects::UserId;
#[derive(Debug, Clone)]
pub struct TopFriend { pub user_id: UserId, pub friend_id: UserId, pub position: i16 }

View File

@@ -0,0 +1,35 @@
use chrono::{DateTime, Utc};
use crate::value_objects::{UserId, Username, Email, PasswordHash};
#[derive(Debug, Clone)]
pub struct User {
pub id: UserId,
pub username: Username,
pub email: Email,
pub password_hash: PasswordHash,
pub display_name: Option<String>,
pub bio: Option<String>,
pub avatar_url: Option<String>,
pub header_url: Option<String>,
pub custom_css: Option<String>,
pub local: bool,
pub ap_id: Option<String>,
pub inbox_url: Option<String>,
pub public_key: Option<String>,
pub private_key: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl User {
pub fn new_local(id: UserId, username: Username, email: Email, password_hash: PasswordHash) -> Self {
let now = Utc::now();
Self {
id, username, email, password_hash,
display_name: None, bio: None, avatar_url: None, header_url: None,
custom_css: None, local: true, ap_id: None, inbox_url: None,
public_key: None, private_key: None,
created_at: now, updated_at: now,
}
}
}

138
crates/domain/src/ports.rs Normal file
View File

@@ -0,0 +1,138 @@
use async_trait::async_trait;
use crate::{
errors::DomainError,
events::{DomainEvent, EventEnvelope},
models::{
api_key::ApiKey,
feed::{FeedEntry, PageParams, Paginated, UserSummary},
notification::Notification,
remote_actor::RemoteActor,
social::{Block, Boost, Follow, FollowState, Like},
tag::Tag,
thought::Thought,
top_friend::TopFriend,
user::User,
},
value_objects::{ApiKeyId, Content, Email, NotificationId, PasswordHash, ThoughtId, UserId, Username},
};
pub struct GeneratedToken { pub token: String, pub user_id: UserId }
#[async_trait]
pub trait AuthService: Send + Sync {
fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError>;
fn validate_token(&self, token: &str) -> Result<UserId, DomainError>;
}
#[async_trait]
pub trait PasswordHasher: Send + Sync {
async fn hash(&self, plain: &str) -> Result<PasswordHash, DomainError>;
async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result<bool, DomainError>;
}
#[async_trait]
pub trait EventPublisher: Send + Sync {
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>;
}
pub trait EventConsumer: Send + Sync {
fn consume(&self) -> futures::stream::BoxStream<'_, Result<EventEnvelope, DomainError>>;
}
#[async_trait]
pub trait UserRepository: Send + Sync {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError>;
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
async fn save(&self, user: &User) -> Result<(), DomainError>;
async fn update_profile(&self, user_id: &UserId, display_name: Option<String>, bio: Option<String>, avatar_url: Option<String>, header_url: Option<String>, custom_css: Option<String>) -> Result<(), DomainError>;
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError>;
}
#[async_trait]
pub trait ThoughtRepository: Send + Sync {
async fn save(&self, thought: &Thought) -> Result<(), DomainError>;
async fn find_by_id(&self, id: &ThoughtId) -> Result<Option<Thought>, DomainError>;
async fn delete(&self, id: &ThoughtId, user_id: &UserId) -> Result<(), DomainError>;
async fn update_content(&self, id: &ThoughtId, content: &Content) -> Result<(), DomainError>;
async fn get_thread(&self, id: &ThoughtId) -> Result<Vec<Thought>, DomainError>;
async fn list_by_user(&self, user_id: &UserId, page: &PageParams) -> Result<Paginated<FeedEntry>, DomainError>;
}
#[async_trait]
pub trait LikeRepository: Send + Sync {
async fn save(&self, like: &Like) -> Result<(), DomainError>;
async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError>;
async fn find(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<Option<Like>, DomainError>;
async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result<i64, DomainError>;
}
#[async_trait]
pub trait BoostRepository: Send + Sync {
async fn save(&self, boost: &Boost) -> Result<(), DomainError>;
async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError>;
async fn find(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<Option<Boost>, DomainError>;
async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result<i64, DomainError>;
}
#[async_trait]
pub trait FollowRepository: Send + Sync {
async fn save(&self, follow: &Follow) -> Result<(), DomainError>;
async fn delete(&self, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError>;
async fn find(&self, follower_id: &UserId, following_id: &UserId) -> Result<Option<Follow>, DomainError>;
async fn update_state(&self, follower_id: &UserId, following_id: &UserId, state: &FollowState) -> Result<(), DomainError>;
async fn list_followers(&self, user_id: &UserId, page: &PageParams) -> Result<Paginated<User>, DomainError>;
async fn list_following(&self, user_id: &UserId, page: &PageParams) -> Result<Paginated<User>, DomainError>;
async fn get_accepted_following_ids(&self, user_id: &UserId) -> Result<Vec<UserId>, DomainError>;
}
#[async_trait]
pub trait BlockRepository: Send + Sync {
async fn save(&self, block: &Block) -> Result<(), DomainError>;
async fn delete(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError>;
async fn exists(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<bool, DomainError>;
}
#[async_trait]
pub trait TagRepository: Send + Sync {
async fn find_or_create(&self, name: &str) -> Result<Tag, DomainError>;
async fn attach_to_thought(&self, thought_id: &ThoughtId, tag_id: i32) -> Result<(), DomainError>;
async fn detach_from_thought(&self, thought_id: &ThoughtId) -> Result<(), DomainError>;
async fn list_for_thought(&self, thought_id: &ThoughtId) -> Result<Vec<Tag>, DomainError>;
async fn list_thoughts_by_tag(&self, tag_name: &str, page: &PageParams) -> Result<Paginated<Thought>, DomainError>;
}
#[async_trait]
pub trait ApiKeyRepository: Send + Sync {
async fn save(&self, key: &ApiKey) -> Result<(), DomainError>;
async fn find_by_hash(&self, key_hash: &str) -> Result<Option<ApiKey>, DomainError>;
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<ApiKey>, DomainError>;
async fn delete(&self, id: &ApiKeyId, user_id: &UserId) -> Result<(), DomainError>;
}
#[async_trait]
pub trait TopFriendRepository: Send + Sync {
async fn set_top_friends(&self, user_id: &UserId, friends: Vec<(UserId, i16)>) -> Result<(), DomainError>;
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<(TopFriend, User)>, DomainError>;
}
#[async_trait]
pub trait NotificationRepository: Send + Sync {
async fn save(&self, n: &Notification) -> Result<(), DomainError>;
async fn list_for_user(&self, user_id: &UserId, page: &PageParams) -> Result<Paginated<Notification>, DomainError>;
async fn mark_read(&self, id: &NotificationId, user_id: &UserId) -> Result<(), DomainError>;
async fn mark_all_read(&self, user_id: &UserId) -> Result<(), DomainError>;
}
#[async_trait]
pub trait RemoteActorRepository: Send + Sync {
async fn upsert(&self, actor: &RemoteActor) -> Result<(), DomainError>;
async fn find_by_url(&self, url: &str) -> Result<Option<RemoteActor>, DomainError>;
}
#[async_trait]
pub trait FeedRepository: Send + Sync {
async fn home_feed(&self, following_ids: &[UserId], page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError>;
async fn public_feed(&self, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError>;
async fn search(&self, query: &str, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError>;
}

View File

@@ -0,0 +1,295 @@
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use chrono::Utc;
use crate::{
errors::DomainError,
events::DomainEvent,
models::{
api_key::ApiKey,
feed::{FeedEntry, PageParams, Paginated, UserSummary},
notification::Notification,
remote_actor::RemoteActor,
social::{Block, Boost, Follow, FollowState, Like},
tag::Tag,
thought::Thought,
top_friend::TopFriend,
user::User,
},
ports::*,
value_objects::{ApiKeyId, Content, Email, NotificationId, ThoughtId, UserId, Username},
};
#[derive(Default, Clone)]
pub struct TestStore {
pub users: Arc<Mutex<Vec<User>>>,
pub thoughts: Arc<Mutex<Vec<Thought>>>,
pub likes: Arc<Mutex<Vec<Like>>>,
pub boosts: Arc<Mutex<Vec<Boost>>>,
pub follows: Arc<Mutex<Vec<Follow>>>,
pub blocks: Arc<Mutex<Vec<Block>>>,
pub tags: Arc<Mutex<Vec<Tag>>>,
pub api_keys: Arc<Mutex<Vec<ApiKey>>>,
pub top_friends: Arc<Mutex<Vec<TopFriend>>>,
pub notifications: Arc<Mutex<Vec<Notification>>>,
pub events: Arc<Mutex<Vec<DomainEvent>>>,
}
#[async_trait] impl UserRepository for TestStore {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
Ok(self.users.lock().unwrap().iter().find(|u| &u.id == id).cloned())
}
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
Ok(self.users.lock().unwrap().iter().find(|u| u.username.as_str() == username.as_str()).cloned())
}
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
Ok(self.users.lock().unwrap().iter().find(|u| u.email.as_str() == email.as_str()).cloned())
}
async fn save(&self, user: &User) -> Result<(), DomainError> {
let mut g = self.users.lock().unwrap();
g.retain(|u| u.id != user.id);
g.push(user.clone());
Ok(())
}
async fn update_profile(&self, user_id: &UserId, display_name: Option<String>, bio: Option<String>, avatar_url: Option<String>, header_url: Option<String>, custom_css: Option<String>) -> Result<(), DomainError> {
if let Some(u) = self.users.lock().unwrap().iter_mut().find(|u| &u.id == user_id) {
u.display_name = display_name;
u.bio = bio;
u.avatar_url = avatar_url;
u.header_url = header_url;
u.custom_css = custom_css;
}
Ok(())
}
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> { Ok(vec![]) }
}
#[async_trait] impl ThoughtRepository for TestStore {
async fn save(&self, t: &Thought) -> Result<(), DomainError> {
let mut g = self.thoughts.lock().unwrap();
g.retain(|x| x.id != t.id);
g.push(t.clone());
Ok(())
}
async fn find_by_id(&self, id: &ThoughtId) -> Result<Option<Thought>, DomainError> {
Ok(self.thoughts.lock().unwrap().iter().find(|t| &t.id == id).cloned())
}
async fn delete(&self, id: &ThoughtId, user_id: &UserId) -> Result<(), DomainError> {
let mut g = self.thoughts.lock().unwrap();
let before = g.len();
g.retain(|t| !(&t.id == id && &t.user_id == user_id));
if g.len() == before { return Err(DomainError::NotFound); }
Ok(())
}
async fn update_content(&self, id: &ThoughtId, content: &Content) -> Result<(), DomainError> {
if let Some(t) = self.thoughts.lock().unwrap().iter_mut().find(|t| &t.id == id) {
t.content = content.clone();
t.updated_at = Some(Utc::now());
}
Ok(())
}
async fn get_thread(&self, id: &ThoughtId) -> Result<Vec<Thought>, DomainError> {
Ok(self.thoughts.lock().unwrap().iter()
.filter(|t| t.in_reply_to_id.as_ref() == Some(id) || &t.id == id)
.cloned().collect())
}
async fn list_by_user(&self, _user_id: &UserId, _page: &PageParams) -> Result<Paginated<FeedEntry>, DomainError> {
Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 })
}
}
#[async_trait] impl LikeRepository for TestStore {
async fn save(&self, like: &Like) -> Result<(), DomainError> {
let mut g = self.likes.lock().unwrap();
if g.iter().any(|l| l.user_id == like.user_id && l.thought_id == like.thought_id) {
return Err(DomainError::Conflict("already liked".into()));
}
g.push(like.clone());
Ok(())
}
async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> {
let mut g = self.likes.lock().unwrap();
let before = g.len();
g.retain(|l| !(&l.user_id == user_id && &l.thought_id == thought_id));
if g.len() == before { return Err(DomainError::NotFound); }
Ok(())
}
async fn find(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<Option<Like>, DomainError> {
Ok(self.likes.lock().unwrap().iter().find(|l| &l.user_id == user_id && &l.thought_id == thought_id).cloned())
}
async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result<i64, DomainError> {
Ok(self.likes.lock().unwrap().iter().filter(|l| &l.thought_id == thought_id).count() as i64)
}
}
#[async_trait] impl BoostRepository for TestStore {
async fn save(&self, boost: &Boost) -> Result<(), DomainError> {
let mut g = self.boosts.lock().unwrap();
if g.iter().any(|b| b.user_id == boost.user_id && b.thought_id == boost.thought_id) {
return Err(DomainError::Conflict("already boosted".into()));
}
g.push(boost.clone());
Ok(())
}
async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> {
let mut g = self.boosts.lock().unwrap();
let before = g.len();
g.retain(|b| !(&b.user_id == user_id && &b.thought_id == thought_id));
if g.len() == before { return Err(DomainError::NotFound); }
Ok(())
}
async fn find(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<Option<Boost>, DomainError> {
Ok(self.boosts.lock().unwrap().iter().find(|b| &b.user_id == user_id && &b.thought_id == thought_id).cloned())
}
async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result<i64, DomainError> {
Ok(self.boosts.lock().unwrap().iter().filter(|b| &b.thought_id == thought_id).count() as i64)
}
}
#[async_trait] impl FollowRepository for TestStore {
async fn save(&self, follow: &Follow) -> Result<(), DomainError> {
let mut g = self.follows.lock().unwrap();
g.retain(|f| !(f.follower_id == follow.follower_id && f.following_id == follow.following_id));
g.push(follow.clone());
Ok(())
}
async fn delete(&self, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> {
let mut g = self.follows.lock().unwrap();
let before = g.len();
g.retain(|f| !(&f.follower_id == follower_id && &f.following_id == following_id));
if g.len() == before { return Err(DomainError::NotFound); }
Ok(())
}
async fn find(&self, follower_id: &UserId, following_id: &UserId) -> Result<Option<Follow>, DomainError> {
Ok(self.follows.lock().unwrap().iter().find(|f| &f.follower_id == follower_id && &f.following_id == following_id).cloned())
}
async fn update_state(&self, follower_id: &UserId, following_id: &UserId, state: &FollowState) -> Result<(), DomainError> {
if let Some(f) = self.follows.lock().unwrap().iter_mut().find(|f| &f.follower_id == follower_id && &f.following_id == following_id) {
f.state = state.clone();
}
Ok(())
}
async fn list_followers(&self, _user_id: &UserId, _p: &PageParams) -> Result<Paginated<User>, DomainError> {
Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 })
}
async fn list_following(&self, _user_id: &UserId, _p: &PageParams) -> Result<Paginated<User>, DomainError> {
Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 })
}
async fn get_accepted_following_ids(&self, user_id: &UserId) -> Result<Vec<UserId>, DomainError> {
Ok(self.follows.lock().unwrap().iter()
.filter(|f| &f.follower_id == user_id && f.state == FollowState::Accepted)
.map(|f| f.following_id.clone())
.collect())
}
}
#[async_trait] impl BlockRepository for TestStore {
async fn save(&self, block: &Block) -> Result<(), DomainError> {
self.blocks.lock().unwrap().push(block.clone());
Ok(())
}
async fn delete(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError> {
self.blocks.lock().unwrap().retain(|b| !(&b.blocker_id == blocker_id && &b.blocked_id == blocked_id));
Ok(())
}
async fn exists(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<bool, DomainError> {
Ok(self.blocks.lock().unwrap().iter().any(|b| &b.blocker_id == blocker_id && &b.blocked_id == blocked_id))
}
}
#[async_trait] impl TagRepository for TestStore {
async fn find_or_create(&self, name: &str) -> Result<Tag, DomainError> {
let mut g = self.tags.lock().unwrap();
if let Some(t) = g.iter().find(|t| t.name == name) { return Ok(t.clone()); }
let tag = Tag { id: g.len() as i32 + 1, name: name.to_string() };
g.push(tag.clone());
Ok(tag)
}
async fn attach_to_thought(&self, _tid: &ThoughtId, _tag_id: i32) -> Result<(), DomainError> { Ok(()) }
async fn detach_from_thought(&self, _tid: &ThoughtId) -> Result<(), DomainError> { Ok(()) }
async fn list_for_thought(&self, _tid: &ThoughtId) -> Result<Vec<Tag>, DomainError> { Ok(vec![]) }
async fn list_thoughts_by_tag(&self, _name: &str, _p: &PageParams) -> Result<Paginated<Thought>, DomainError> {
Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 })
}
}
#[async_trait] impl ApiKeyRepository for TestStore {
async fn save(&self, key: &ApiKey) -> Result<(), DomainError> {
self.api_keys.lock().unwrap().push(key.clone());
Ok(())
}
async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKey>, DomainError> {
Ok(self.api_keys.lock().unwrap().iter().find(|k| k.key_hash == hash).cloned())
}
async fn list_for_user(&self, uid: &UserId) -> Result<Vec<ApiKey>, DomainError> {
Ok(self.api_keys.lock().unwrap().iter().filter(|k| &k.user_id == uid).cloned().collect())
}
async fn delete(&self, id: &ApiKeyId, uid: &UserId) -> Result<(), DomainError> {
self.api_keys.lock().unwrap().retain(|k| !(&k.id == id && &k.user_id == uid));
Ok(())
}
}
#[async_trait] impl TopFriendRepository for TestStore {
async fn set_top_friends(&self, user_id: &UserId, friends: Vec<(UserId, i16)>) -> Result<(), DomainError> {
let mut g = self.top_friends.lock().unwrap();
g.retain(|tf| &tf.user_id != user_id);
for (fid, pos) in friends {
g.push(TopFriend { user_id: user_id.clone(), friend_id: fid, position: pos });
}
Ok(())
}
async fn list_for_user(&self, _uid: &UserId) -> Result<Vec<(TopFriend, User)>, DomainError> { Ok(vec![]) }
}
#[async_trait] impl NotificationRepository for TestStore {
async fn save(&self, n: &Notification) -> Result<(), DomainError> {
self.notifications.lock().unwrap().push(n.clone());
Ok(())
}
async fn list_for_user(&self, uid: &UserId, _p: &PageParams) -> Result<Paginated<Notification>, DomainError> {
let items: Vec<_> = self.notifications.lock().unwrap().iter().filter(|n| &n.user_id == uid).cloned().collect();
let total = items.len() as i64;
Ok(Paginated { items, total, page: 1, per_page: 20 })
}
async fn mark_read(&self, id: &NotificationId, _uid: &UserId) -> Result<(), DomainError> {
if let Some(n) = self.notifications.lock().unwrap().iter_mut().find(|n| &n.id == id) {
n.read = true;
}
Ok(())
}
async fn mark_all_read(&self, uid: &UserId) -> Result<(), DomainError> {
for n in self.notifications.lock().unwrap().iter_mut().filter(|n| &n.user_id == uid) {
n.read = true;
}
Ok(())
}
}
#[async_trait] impl RemoteActorRepository for TestStore {
async fn upsert(&self, _a: &RemoteActor) -> Result<(), DomainError> { Ok(()) }
async fn find_by_url(&self, _url: &str) -> Result<Option<RemoteActor>, DomainError> { Ok(None) }
}
#[async_trait] impl FeedRepository for TestStore {
async fn home_feed(&self, _ids: &[UserId], _p: &PageParams, _v: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 })
}
async fn public_feed(&self, _p: &PageParams, _v: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 })
}
async fn search(&self, _q: &str, _p: &PageParams, _v: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 })
}
}
#[async_trait] impl EventPublisher for TestStore {
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
self.events.lock().unwrap().push(event.clone());
Ok(())
}
}
pub struct NoOpEventPublisher;
#[async_trait] impl EventPublisher for NoOpEventPublisher {
async fn publish(&self, _e: &DomainEvent) -> Result<(), DomainError> { Ok(()) }
}

View File

@@ -0,0 +1,117 @@
use uuid::Uuid;
use crate::errors::DomainError;
macro_rules! uuid_id {
($name:ident) => {
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct $name(Uuid);
impl $name {
pub fn new() -> Self { Self(Uuid::new_v4()) }
pub fn from_uuid(u: Uuid) -> Self { Self(u) }
pub fn as_uuid(&self) -> Uuid { self.0 }
}
impl Default for $name {
fn default() -> Self { Self::new() }
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
};
}
uuid_id!(UserId);
uuid_id!(ThoughtId);
uuid_id!(LikeId);
uuid_id!(BoostId);
uuid_id!(ApiKeyId);
uuid_id!(NotificationId);
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Username(String);
impl Username {
pub fn new(s: impl Into<String>) -> Result<Self, DomainError> {
let s = s.into();
if s.is_empty() || s.len() > 32 {
return Err(DomainError::InvalidInput("username: 1-32 chars".into()));
}
if !s.chars().all(|c| c.is_alphanumeric() || c == '_') {
return Err(DomainError::InvalidInput("username: alphanumeric or underscore only".into()));
}
Ok(Self(s))
}
pub fn from_trusted(s: String) -> Self { Self(s) }
pub fn as_str(&self) -> &str { &self.0 }
}
impl std::fmt::Display for Username {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) }
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Email(String);
impl Email {
pub fn new(s: impl Into<String>) -> Result<Self, DomainError> {
let s = s.into().to_lowercase();
if !s.contains('@') || s.len() > 255 {
return Err(DomainError::InvalidInput("invalid email".into()));
}
Ok(Self(s))
}
pub fn from_trusted(s: String) -> Self { Self(s) }
pub fn as_str(&self) -> &str { &self.0 }
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PasswordHash(pub String);
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Content(String);
impl Content {
pub fn new_local(s: impl Into<String>) -> Result<Self, DomainError> {
let s = s.into();
if s.is_empty() || s.len() > 128 {
return Err(DomainError::InvalidInput("content: 1-128 chars".into()));
}
Ok(Self(s))
}
pub fn new_remote(s: impl Into<String>) -> Self { Self(s.into()) }
pub fn as_str(&self) -> &str { &self.0 }
}
impl std::fmt::Display for Content {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn username_rejects_empty() {
assert!(Username::new("").is_err());
}
#[test]
fn username_rejects_too_long() {
assert!(Username::new("a".repeat(33)).is_err());
}
#[test]
fn username_rejects_invalid_chars() {
assert!(Username::new("hello world").is_err());
}
#[test]
fn username_accepts_valid() {
assert!(Username::new("hello_123").is_ok());
}
#[test]
fn content_local_rejects_over_128() {
assert!(Content::new_local("a".repeat(129)).is_err());
}
#[test]
fn content_local_accepts_128() {
assert!(Content::new_local("a".repeat(128)).is_ok());
}
#[test]
fn email_rejects_no_at() {
assert!(Email::new("notanemail").is_err());
}
}

View File

@@ -0,0 +1,33 @@
[package]
name = "presentation"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "thoughts"
path = "src/main.rs"
[dependencies]
domain = { workspace = true }
application = { workspace = true }
api-types = { workspace = true }
postgres = { workspace = true }
auth = { 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 }
tracing-subscriber = { workspace = true }
dotenvy = { workspace = true }
async-trait = { workspace = true }
sha2 = "0.10"
hex = "0.4"
[dev-dependencies]
http-body-util = "0.1"
tower = "0.5"
domain = { workspace = true, features = ["test-helpers"] }

View File

@@ -0,0 +1 @@
fn main() {}

4
crates/worker/Cargo.toml Normal file
View File

@@ -0,0 +1,4 @@
[package]
name = "worker"
version = "0.1.0"
edition = "2021"

View File

@@ -0,0 +1 @@
fn main() {}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,285 @@
# 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 (18)
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.