Compare commits
20 Commits
master
...
f75e796faf
| Author | SHA1 | Date | |
|---|---|---|---|
| f75e796faf | |||
| c5d262c68f | |||
| 38106ecdb6 | |||
| fb39ea2469 | |||
| adc2102927 | |||
| 134ecdcfb4 | |||
| 2b428b2b0a | |||
| 69608cfc75 | |||
| 02ce3a49b4 | |||
| 1dab9ffbfb | |||
| 9dd04541ac | |||
| fe9655ee96 | |||
| 62ee73e302 | |||
| 80b656341d | |||
| 4b8d1027c1 | |||
| 94a3f414e4 | |||
| 63a7001165 | |||
| 321571aae9 | |||
| 9d6e3298f1 | |||
| 6fd9a76e68 |
48
Cargo.toml
Normal file
48
Cargo.toml
Normal 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" }
|
||||||
4
crates/adapters/activitypub-base/Cargo.toml
Normal file
4
crates/adapters/activitypub-base/Cargo.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[package]
|
||||||
|
name = "activitypub-base"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
0
crates/adapters/activitypub-base/src/lib.rs
Normal file
0
crates/adapters/activitypub-base/src/lib.rs
Normal file
4
crates/adapters/activitypub/Cargo.toml
Normal file
4
crates/adapters/activitypub/Cargo.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[package]
|
||||||
|
name = "activitypub"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
0
crates/adapters/activitypub/src/lib.rs
Normal file
0
crates/adapters/activitypub/src/lib.rs
Normal file
16
crates/adapters/auth/Cargo.toml
Normal file
16
crates/adapters/auth/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[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 }
|
||||||
|
serde = { workspace = true }
|
||||||
|
jsonwebtoken = "9"
|
||||||
|
argon2 = "0.5"
|
||||||
|
rand = "0.8"
|
||||||
116
crates/adapters/auth/src/lib.rs
Normal file
116
crates/adapters/auth/src/lib.rs
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
ports::{AuthService, GeneratedToken, PasswordHasher},
|
||||||
|
value_objects::{PasswordHash, UserId},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct Claims {
|
||||||
|
sub: String,
|
||||||
|
exp: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct JwtAuthService {
|
||||||
|
secret: String,
|
||||||
|
ttl_seconds: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JwtAuthService {
|
||||||
|
pub fn new(secret: String, ttl_seconds: i64) -> Self {
|
||||||
|
Self { secret, ttl_seconds }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthService for JwtAuthService {
|
||||||
|
fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError> {
|
||||||
|
let exp = (Utc::now() + Duration::seconds(self.ttl_seconds)).timestamp() as usize;
|
||||||
|
let claims = Claims {
|
||||||
|
sub: user_id.as_uuid().to_string(),
|
||||||
|
exp,
|
||||||
|
};
|
||||||
|
let token = encode(
|
||||||
|
&Header::default(),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(self.secret.as_bytes()),
|
||||||
|
)
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
Ok(GeneratedToken {
|
||||||
|
token,
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_token(&self, token: &str) -> Result<UserId, DomainError> {
|
||||||
|
let data = decode::<Claims>(
|
||||||
|
token,
|
||||||
|
&DecodingKey::from_secret(self.secret.as_bytes()),
|
||||||
|
&Validation::default(),
|
||||||
|
)
|
||||||
|
.map_err(|_| DomainError::Unauthorized)?;
|
||||||
|
let uuid = uuid::Uuid::parse_str(&data.claims.sub)
|
||||||
|
.map_err(|_| DomainError::Unauthorized)?;
|
||||||
|
Ok(UserId::from_uuid(uuid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Argon2PasswordHasher;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PasswordHasher for Argon2PasswordHasher {
|
||||||
|
async fn hash(&self, plain: &str) -> Result<PasswordHash, DomainError> {
|
||||||
|
use argon2::{
|
||||||
|
password_hash::SaltString,
|
||||||
|
Argon2, PasswordHasher as _,
|
||||||
|
};
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
let salt = SaltString::generate(OsRng);
|
||||||
|
let hash = Argon2::default()
|
||||||
|
.hash_password(plain.as_bytes(), &salt)
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?
|
||||||
|
.to_string();
|
||||||
|
Ok(PasswordHash(hash))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
|
||||||
|
use argon2::{password_hash::PasswordHash as ArgonHash, Argon2, PasswordVerifier};
|
||||||
|
let parsed = ArgonHash::new(&hash.0)
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
Ok(Argon2::default()
|
||||||
|
.verify_password(plain.as_bytes(), &parsed)
|
||||||
|
.is_ok())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use domain::ports::AuthService;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_and_validate_token() {
|
||||||
|
let svc = JwtAuthService::new("secret".into(), 3600);
|
||||||
|
let id = UserId::new();
|
||||||
|
let tok = svc.generate_token(&id).unwrap();
|
||||||
|
let parsed = svc.validate_token(&tok.token).unwrap();
|
||||||
|
assert_eq!(parsed.as_uuid(), id.as_uuid());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_token_returns_unauthorized() {
|
||||||
|
let svc = JwtAuthService::new("secret".into(), 3600);
|
||||||
|
let err = svc.validate_token("not.a.token").unwrap_err();
|
||||||
|
assert!(matches!(err, DomainError::Unauthorized));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hash_and_verify() {
|
||||||
|
let hasher = Argon2PasswordHasher;
|
||||||
|
let hash = hasher.hash("mypassword").await.unwrap();
|
||||||
|
assert!(hasher.verify("mypassword", &hash).await.unwrap());
|
||||||
|
assert!(!hasher.verify("wrongpassword", &hash).await.unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
4
crates/adapters/event-payload/Cargo.toml
Normal file
4
crates/adapters/event-payload/Cargo.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[package]
|
||||||
|
name = "event-payload"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
0
crates/adapters/event-payload/src/lib.rs
Normal file
0
crates/adapters/event-payload/src/lib.rs
Normal file
4
crates/adapters/event-publisher/Cargo.toml
Normal file
4
crates/adapters/event-publisher/Cargo.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[package]
|
||||||
|
name = "event-publisher"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
0
crates/adapters/event-publisher/src/lib.rs
Normal file
0
crates/adapters/event-publisher/src/lib.rs
Normal file
4
crates/adapters/nats/Cargo.toml
Normal file
4
crates/adapters/nats/Cargo.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[package]
|
||||||
|
name = "nats"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
0
crates/adapters/nats/src/lib.rs
Normal file
0
crates/adapters/nats/src/lib.rs
Normal file
4
crates/adapters/postgres-federation/Cargo.toml
Normal file
4
crates/adapters/postgres-federation/Cargo.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[package]
|
||||||
|
name = "postgres-federation"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
0
crates/adapters/postgres-federation/src/lib.rs
Normal file
0
crates/adapters/postgres-federation/src/lib.rs
Normal file
4
crates/adapters/postgres-search/Cargo.toml
Normal file
4
crates/adapters/postgres-search/Cargo.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[package]
|
||||||
|
name = "postgres-search"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
0
crates/adapters/postgres-search/src/lib.rs
Normal file
0
crates/adapters/postgres-search/src/lib.rs
Normal file
17
crates/adapters/postgres/Cargo.toml
Normal file
17
crates/adapters/postgres/Cargo.toml
Normal 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"] }
|
||||||
55
crates/adapters/postgres/migrations/001_initial_schema.sql
Normal file
55
crates/adapters/postgres/migrations/001_initial_schema.sql
Normal 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()
|
||||||
|
);
|
||||||
@@ -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();
|
||||||
49
crates/adapters/postgres/migrations/003_new_tables.sql
Normal file
49
crates/adapters/postgres/migrations/003_new_tables.sql
Normal 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);
|
||||||
73
crates/adapters/postgres/src/api_key.rs
Normal file
73
crates/adapters/postgres/src/api_key.rs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use domain::{errors::DomainError, models::api_key::ApiKey, ports::ApiKeyRepository, value_objects::{ApiKeyId, UserId}};
|
||||||
|
|
||||||
|
pub struct PgApiKeyRepository { pool: PgPool }
|
||||||
|
impl PgApiKeyRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ApiKeyRepository for PgApiKeyRepository {
|
||||||
|
async fn save(&self, k: &ApiKey) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("INSERT INTO api_keys(id,user_id,key_hash,name,created_at) VALUES($1,$2,$3,$4,$5)")
|
||||||
|
.bind(k.id.as_uuid()).bind(k.user_id.as_uuid()).bind(&k.key_hash).bind(&k.name).bind(k.created_at)
|
||||||
|
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKey>, DomainError> {
|
||||||
|
#[derive(sqlx::FromRow)] struct Row { id: uuid::Uuid, user_id: uuid::Uuid, key_hash: String, name: String, created_at: DateTime<Utc> }
|
||||||
|
sqlx::query_as::<_, Row>("SELECT id,user_id,key_hash,name,created_at FROM api_keys WHERE key_hash=$1")
|
||||||
|
.bind(hash).fetch_optional(&self.pool).await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
.map(|o| o.map(|r| ApiKey { id: ApiKeyId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), key_hash: r.key_hash, name: r.name, created_at: r.created_at }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<ApiKey>, DomainError> {
|
||||||
|
#[derive(sqlx::FromRow)] struct Row { id: uuid::Uuid, user_id: uuid::Uuid, key_hash: String, name: String, created_at: DateTime<Utc> }
|
||||||
|
sqlx::query_as::<_, Row>("SELECT id,user_id,key_hash,name,created_at FROM api_keys WHERE user_id=$1 ORDER BY 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| ApiKey { id: ApiKeyId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), key_hash: r.key_hash, name: r.name, created_at: r.created_at }).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: &ApiKeyId, user_id: &UserId) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("DELETE FROM api_keys 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())).map(|_| ())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::Utc;
|
||||||
|
use domain::{models::user::User, value_objects::*};
|
||||||
|
use crate::user::PgUserRepository;
|
||||||
|
use domain::ports::UserRepository;
|
||||||
|
|
||||||
|
async fn seed_user(pool: &sqlx::PgPool) -> User {
|
||||||
|
let repo = PgUserRepository::new(pool.clone());
|
||||||
|
let u = User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into()));
|
||||||
|
repo.save(&u).await.unwrap(); u
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn save_and_find_by_hash(pool: sqlx::PgPool) {
|
||||||
|
let user = seed_user(&pool).await;
|
||||||
|
let repo = PgApiKeyRepository::new(pool);
|
||||||
|
let key = ApiKey { id: ApiKeyId::new(), user_id: user.id.clone(), key_hash: "abc123".into(), name: "test".into(), created_at: Utc::now() };
|
||||||
|
repo.save(&key).await.unwrap();
|
||||||
|
let found = repo.find_by_hash("abc123").await.unwrap().unwrap();
|
||||||
|
assert_eq!(found.name, "test");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn delete_key(pool: sqlx::PgPool) {
|
||||||
|
let user = seed_user(&pool).await;
|
||||||
|
let repo = PgApiKeyRepository::new(pool);
|
||||||
|
let key = ApiKey { id: ApiKeyId::new(), user_id: user.id.clone(), key_hash: "def456".into(), name: "key2".into(), created_at: Utc::now() };
|
||||||
|
repo.save(&key).await.unwrap();
|
||||||
|
repo.delete(&key.id, &user.id).await.unwrap();
|
||||||
|
assert!(repo.find_by_hash("def456").await.unwrap().is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
81
crates/adapters/postgres/src/block.rs
Normal file
81
crates/adapters/postgres/src/block.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use domain::{errors::DomainError, models::social::Block, ports::BlockRepository, value_objects::UserId};
|
||||||
|
|
||||||
|
pub struct PgBlockRepository { pool: PgPool }
|
||||||
|
impl PgBlockRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl BlockRepository for PgBlockRepository {
|
||||||
|
async fn save(&self, b: &Block) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO blocks(blocker_id,blocked_id,created_at) VALUES($1,$2,$3) ON CONFLICT DO NOTHING"
|
||||||
|
)
|
||||||
|
.bind(b.blocker_id.as_uuid())
|
||||||
|
.bind(b.blocked_id.as_uuid())
|
||||||
|
.bind(b.created_at)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("DELETE FROM blocks WHERE blocker_id=$1 AND blocked_id=$2")
|
||||||
|
.bind(blocker_id.as_uuid())
|
||||||
|
.bind(blocked_id.as_uuid())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exists(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<bool, DomainError> {
|
||||||
|
let count: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM blocks WHERE blocker_id=$1 AND blocked_id=$2"
|
||||||
|
)
|
||||||
|
.bind(blocker_id.as_uuid())
|
||||||
|
.bind(blocked_id.as_uuid())
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::Utc;
|
||||||
|
use domain::{models::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 block_exists(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 = PgBlockRepository::new(pool);
|
||||||
|
let block = Block { blocker_id: alice.id.clone(), blocked_id: bob.id.clone(), created_at: Utc::now() };
|
||||||
|
repo.save(&block).await.unwrap();
|
||||||
|
assert!(repo.exists(&alice.id, &bob.id).await.unwrap());
|
||||||
|
assert!(!repo.exists(&bob.id, &alice.id).await.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn unblock(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 = PgBlockRepository::new(pool);
|
||||||
|
let block = Block { blocker_id: alice.id.clone(), blocked_id: bob.id.clone(), created_at: Utc::now() };
|
||||||
|
repo.save(&block).await.unwrap();
|
||||||
|
repo.delete(&alice.id, &bob.id).await.unwrap();
|
||||||
|
assert!(!repo.exists(&alice.id, &bob.id).await.unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
80
crates/adapters/postgres/src/boost.rs
Normal file
80
crates/adapters/postgres/src/boost.rs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use domain::{errors::DomainError, models::social::Boost, ports::BoostRepository, value_objects::{BoostId, ThoughtId, UserId}};
|
||||||
|
|
||||||
|
pub struct PgBoostRepository { pool: PgPool }
|
||||||
|
impl PgBoostRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl BoostRepository for PgBoostRepository {
|
||||||
|
async fn save(&self, b: &Boost) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO boosts(id,user_id,thought_id,ap_id,created_at) VALUES($1,$2,$3,$4,$5) ON CONFLICT(user_id,thought_id) DO NOTHING"
|
||||||
|
)
|
||||||
|
.bind(b.id.as_uuid()).bind(b.user_id.as_uuid()).bind(b.thought_id.as_uuid()).bind(&b.ap_id).bind(b.created_at)
|
||||||
|
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> {
|
||||||
|
let r = sqlx::query("DELETE FROM boosts WHERE user_id=$1 AND thought_id=$2")
|
||||||
|
.bind(user_id.as_uuid()).bind(thought_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 find(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<Option<Boost>, DomainError> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row { id: uuid::Uuid, user_id: uuid::Uuid, thought_id: uuid::Uuid, ap_id: Option<String>, created_at: DateTime<Utc> }
|
||||||
|
sqlx::query_as::<_, Row>("SELECT id,user_id,thought_id,ap_id,created_at FROM boosts WHERE user_id=$1 AND thought_id=$2")
|
||||||
|
.bind(user_id.as_uuid()).bind(thought_id.as_uuid())
|
||||||
|
.fetch_optional(&self.pool).await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
.map(|o| o.map(|r| Boost { id: BoostId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), thought_id: ThoughtId::from_uuid(r.thought_id), ap_id: r.ap_id, created_at: r.created_at }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result<i64, DomainError> {
|
||||||
|
sqlx::query_scalar("SELECT COUNT(*) FROM boosts WHERE thought_id=$1")
|
||||||
|
.bind(thought_id.as_uuid()).fetch_one(&self.pool).await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::Utc;
|
||||||
|
use domain::{models::{thought::{Thought, Visibility}, user::User}, value_objects::*};
|
||||||
|
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
||||||
|
use domain::ports::{ThoughtRepository, UserRepository};
|
||||||
|
|
||||||
|
async fn seed(pool: &sqlx::PgPool) -> (User, Thought) {
|
||||||
|
let urepo = PgUserRepository::new(pool.clone());
|
||||||
|
let trepo = PgThoughtRepository::new(pool.clone());
|
||||||
|
let u = User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into()));
|
||||||
|
urepo.save(&u).await.unwrap();
|
||||||
|
let t = Thought::new_local(ThoughtId::new(), u.id.clone(), Content::new_local("hi").unwrap(), None, Visibility::Public, None, false);
|
||||||
|
trepo.save(&t).await.unwrap();
|
||||||
|
(u, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn boost_and_count(pool: sqlx::PgPool) {
|
||||||
|
let (user, thought) = seed(&pool).await;
|
||||||
|
let repo = PgBoostRepository::new(pool);
|
||||||
|
let boost = Boost { id: BoostId::new(), user_id: user.id.clone(), thought_id: thought.id.clone(), ap_id: None, created_at: Utc::now() };
|
||||||
|
repo.save(&boost).await.unwrap();
|
||||||
|
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn unboost(pool: sqlx::PgPool) {
|
||||||
|
let (user, thought) = seed(&pool).await;
|
||||||
|
let repo = PgBoostRepository::new(pool);
|
||||||
|
let boost = Boost { id: BoostId::new(), user_id: user.id.clone(), thought_id: thought.id.clone(), ap_id: None, created_at: Utc::now() };
|
||||||
|
repo.save(&boost).await.unwrap();
|
||||||
|
repo.delete(&user.id, &thought.id).await.unwrap();
|
||||||
|
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
173
crates/adapters/postgres/src/feed.rs
Normal file
173
crates/adapters/postgres/src/feed.rs
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
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::FeedRepository,
|
||||||
|
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
|
||||||
|
};
|
||||||
|
use domain::models::thought::Visibility;
|
||||||
|
|
||||||
|
pub struct PgFeedRepository { pool: PgPool }
|
||||||
|
impl PgFeedRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||||
|
|
||||||
|
#[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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FeedRepository for PgFeedRepository {
|
||||||
|
async fn home_feed(&self, following_ids: &[UserId], page: &PageParams, _viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
let ids: Vec<uuid::Uuid> = following_ids.iter().map(|id| id.as_uuid()).collect();
|
||||||
|
let total: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id=ANY($1) AND t.visibility='public'"
|
||||||
|
).bind(&ids).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let sql = format!("{FEED_SELECT} WHERE t.user_id=ANY($1) AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $2 OFFSET $3");
|
||||||
|
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||||
|
.bind(&ids).bind(page.limit()).bind(page.offset())
|
||||||
|
.fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Paginated { items: rows.into_iter().map(row_to_entry).collect(), total, page: page.page, per_page: page.per_page })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn public_feed(&self, page: &PageParams, _viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
let total: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'"
|
||||||
|
).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let sql = format!("{FEED_SELECT} WHERE t.local=true AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $1 OFFSET $2");
|
||||||
|
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||||
|
.bind(page.limit()).bind(page.offset())
|
||||||
|
.fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Paginated { items: rows.into_iter().map(row_to_entry).collect(), total, page: page.page, per_page: page.per_page })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search(&self, query: &str, page: &PageParams, _viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
let pattern = format!("%{}%", query.replace('%', "\\%").replace('_', "\\_"));
|
||||||
|
let total: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM thoughts t WHERE t.content ILIKE $1 AND t.visibility='public'"
|
||||||
|
).bind(&pattern).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let sql = format!("{FEED_SELECT} WHERE t.content ILIKE $1 AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $2 OFFSET $3");
|
||||||
|
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||||
|
.bind(&pattern).bind(page.limit()).bind(page.offset())
|
||||||
|
.fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Paginated { items: rows.into_iter().map(row_to_entry).collect(), total, page: page.page, per_page: page.per_page })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use domain::{models::{thought::{Thought, Visibility}, user::User}, ports::{ThoughtRepository, UserRepository}, value_objects::*};
|
||||||
|
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
||||||
|
|
||||||
|
async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) {
|
||||||
|
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 = "./migrations")]
|
||||||
|
async fn public_feed_returns_local_thoughts(pool: sqlx::PgPool) {
|
||||||
|
let (_, _) = seed(&pool, "alice", "hello").await;
|
||||||
|
let repo = PgFeedRepository::new(pool);
|
||||||
|
let result = repo.public_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");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
let result = repo.search("hello", &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");
|
||||||
|
}
|
||||||
|
}
|
||||||
194
crates/adapters/postgres/src/follow.rs
Normal file
194
crates/adapters/postgres/src/follow.rs
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::{feed::{PageParams, Paginated}, social::{Follow, FollowState}, user::User},
|
||||||
|
ports::FollowRepository,
|
||||||
|
value_objects::UserId,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct PgFollowRepository { pool: PgPool }
|
||||||
|
impl PgFollowRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FollowRepository for PgFollowRepository {
|
||||||
|
async fn save(&self, f: &Follow) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO follows(follower_id,following_id,state,ap_id,created_at)
|
||||||
|
VALUES($1,$2,$3,$4,$5)
|
||||||
|
ON CONFLICT(follower_id,following_id) DO UPDATE SET state=EXCLUDED.state,ap_id=EXCLUDED.ap_id"
|
||||||
|
)
|
||||||
|
.bind(f.follower_id.as_uuid())
|
||||||
|
.bind(f.following_id.as_uuid())
|
||||||
|
.bind(f.state.as_str())
|
||||||
|
.bind(&f.ap_id)
|
||||||
|
.bind(f.created_at)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> {
|
||||||
|
let r = sqlx::query("DELETE FROM follows WHERE follower_id=$1 AND following_id=$2")
|
||||||
|
.bind(follower_id.as_uuid())
|
||||||
|
.bind(following_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 find(&self, follower_id: &UserId, following_id: &UserId) -> Result<Option<Follow>, DomainError> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row { follower_id: uuid::Uuid, following_id: uuid::Uuid, state: String, ap_id: Option<String>, created_at: DateTime<Utc> }
|
||||||
|
sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT follower_id,following_id,state,ap_id,created_at FROM follows WHERE follower_id=$1 AND following_id=$2"
|
||||||
|
)
|
||||||
|
.bind(follower_id.as_uuid())
|
||||||
|
.bind(following_id.as_uuid())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
.map(|o| o.map(|r| Follow {
|
||||||
|
follower_id: UserId::from_uuid(r.follower_id),
|
||||||
|
following_id: UserId::from_uuid(r.following_id),
|
||||||
|
state: FollowState::from_str(&r.state),
|
||||||
|
ap_id: r.ap_id,
|
||||||
|
created_at: r.created_at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_state(&self, follower_id: &UserId, following_id: &UserId, state: &FollowState) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("UPDATE follows SET state=$3 WHERE follower_id=$1 AND following_id=$2")
|
||||||
|
.bind(follower_id.as_uuid())
|
||||||
|
.bind(following_id.as_uuid())
|
||||||
|
.bind(state.as_str())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_followers(&self, user_id: &UserId, page: &PageParams) -> Result<Paginated<User>, DomainError> {
|
||||||
|
let total: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM follows WHERE following_id=$1 AND state='accepted'"
|
||||||
|
)
|
||||||
|
.bind(user_id.as_uuid())
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, crate::user::UserRow>(
|
||||||
|
"SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.local,u.ap_id,u.inbox_url,u.public_key,u.private_key,u.created_at,u.updated_at
|
||||||
|
FROM users u JOIN follows f ON f.follower_id=u.id
|
||||||
|
WHERE f.following_id=$1 AND f.state='accepted'
|
||||||
|
ORDER BY f.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()))?;
|
||||||
|
|
||||||
|
Ok(Paginated {
|
||||||
|
items: rows.into_iter().map(User::from).collect(),
|
||||||
|
total,
|
||||||
|
page: page.page,
|
||||||
|
per_page: page.per_page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_following(&self, user_id: &UserId, page: &PageParams) -> Result<Paginated<User>, DomainError> {
|
||||||
|
let total: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM follows WHERE follower_id=$1 AND state='accepted'"
|
||||||
|
)
|
||||||
|
.bind(user_id.as_uuid())
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, crate::user::UserRow>(
|
||||||
|
"SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.local,u.ap_id,u.inbox_url,u.public_key,u.private_key,u.created_at,u.updated_at
|
||||||
|
FROM users u JOIN follows f ON f.following_id=u.id
|
||||||
|
WHERE f.follower_id=$1 AND f.state='accepted'
|
||||||
|
ORDER BY f.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()))?;
|
||||||
|
|
||||||
|
Ok(Paginated {
|
||||||
|
items: rows.into_iter().map(User::from).collect(),
|
||||||
|
total,
|
||||||
|
page: page.page,
|
||||||
|
per_page: page.per_page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_accepted_following_ids(&self, user_id: &UserId) -> Result<Vec<UserId>, DomainError> {
|
||||||
|
let ids: Vec<uuid::Uuid> = sqlx::query_scalar(
|
||||||
|
"SELECT following_id FROM follows WHERE follower_id=$1 AND state='accepted'"
|
||||||
|
)
|
||||||
|
.bind(user_id.as_uuid())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
Ok(ids.into_iter().map(UserId::from_uuid).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::Utc;
|
||||||
|
use domain::{models::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_follow(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 = PgFollowRepository::new(pool);
|
||||||
|
let follow = Follow { follower_id: alice.id.clone(), following_id: bob.id.clone(), state: FollowState::Accepted, ap_id: None, created_at: Utc::now() };
|
||||||
|
repo.save(&follow).await.unwrap();
|
||||||
|
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(found.state, FollowState::Accepted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn update_state(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 = PgFollowRepository::new(pool);
|
||||||
|
let follow = Follow { follower_id: alice.id.clone(), following_id: bob.id.clone(), state: FollowState::Pending, ap_id: None, created_at: Utc::now() };
|
||||||
|
repo.save(&follow).await.unwrap();
|
||||||
|
repo.update_state(&alice.id, &bob.id, &FollowState::Accepted).await.unwrap();
|
||||||
|
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(found.state, FollowState::Accepted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn get_accepted_following_ids(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 = PgFollowRepository::new(pool);
|
||||||
|
let follow = Follow { follower_id: alice.id.clone(), following_id: bob.id.clone(), state: FollowState::Accepted, ap_id: None, created_at: Utc::now() };
|
||||||
|
repo.save(&follow).await.unwrap();
|
||||||
|
let ids = repo.get_accepted_following_ids(&alice.id).await.unwrap();
|
||||||
|
assert_eq!(ids, vec![bob.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
crates/adapters/postgres/src/lib.rs
Normal file
12
crates/adapters/postgres/src/lib.rs
Normal 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;
|
||||||
80
crates/adapters/postgres/src/like.rs
Normal file
80
crates/adapters/postgres/src/like.rs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use domain::{errors::DomainError, models::social::Like, ports::LikeRepository, value_objects::{LikeId, ThoughtId, UserId}};
|
||||||
|
|
||||||
|
pub struct PgLikeRepository { pool: PgPool }
|
||||||
|
impl PgLikeRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl LikeRepository for PgLikeRepository {
|
||||||
|
async fn save(&self, l: &Like) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO likes(id,user_id,thought_id,ap_id,created_at) VALUES($1,$2,$3,$4,$5) ON CONFLICT(user_id,thought_id) DO NOTHING"
|
||||||
|
)
|
||||||
|
.bind(l.id.as_uuid()).bind(l.user_id.as_uuid()).bind(l.thought_id.as_uuid()).bind(&l.ap_id).bind(l.created_at)
|
||||||
|
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> {
|
||||||
|
let r = sqlx::query("DELETE FROM likes WHERE user_id=$1 AND thought_id=$2")
|
||||||
|
.bind(user_id.as_uuid()).bind(thought_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 find(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<Option<Like>, DomainError> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row { id: uuid::Uuid, user_id: uuid::Uuid, thought_id: uuid::Uuid, ap_id: Option<String>, created_at: DateTime<Utc> }
|
||||||
|
sqlx::query_as::<_, Row>("SELECT id,user_id,thought_id,ap_id,created_at FROM likes WHERE user_id=$1 AND thought_id=$2")
|
||||||
|
.bind(user_id.as_uuid()).bind(thought_id.as_uuid())
|
||||||
|
.fetch_optional(&self.pool).await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
.map(|o| o.map(|r| Like { id: LikeId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), thought_id: ThoughtId::from_uuid(r.thought_id), ap_id: r.ap_id, created_at: r.created_at }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result<i64, DomainError> {
|
||||||
|
sqlx::query_scalar("SELECT COUNT(*) FROM likes WHERE thought_id=$1")
|
||||||
|
.bind(thought_id.as_uuid()).fetch_one(&self.pool).await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::Utc;
|
||||||
|
use domain::{models::{thought::{Thought, Visibility}, user::User}, value_objects::*};
|
||||||
|
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
||||||
|
use domain::ports::{ThoughtRepository, UserRepository};
|
||||||
|
|
||||||
|
async fn seed(pool: &sqlx::PgPool) -> (User, Thought) {
|
||||||
|
let urepo = PgUserRepository::new(pool.clone());
|
||||||
|
let trepo = PgThoughtRepository::new(pool.clone());
|
||||||
|
let u = User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into()));
|
||||||
|
urepo.save(&u).await.unwrap();
|
||||||
|
let t = Thought::new_local(ThoughtId::new(), u.id.clone(), Content::new_local("hi").unwrap(), None, Visibility::Public, None, false);
|
||||||
|
trepo.save(&t).await.unwrap();
|
||||||
|
(u, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn like_and_count(pool: sqlx::PgPool) {
|
||||||
|
let (user, thought) = seed(&pool).await;
|
||||||
|
let repo = PgLikeRepository::new(pool);
|
||||||
|
let like = Like { id: LikeId::new(), user_id: user.id.clone(), thought_id: thought.id.clone(), ap_id: None, created_at: Utc::now() };
|
||||||
|
repo.save(&like).await.unwrap();
|
||||||
|
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn unlike(pool: sqlx::PgPool) {
|
||||||
|
let (user, thought) = seed(&pool).await;
|
||||||
|
let repo = PgLikeRepository::new(pool);
|
||||||
|
let like = Like { id: LikeId::new(), user_id: user.id.clone(), thought_id: thought.id.clone(), ap_id: None, created_at: Utc::now() };
|
||||||
|
repo.save(&like).await.unwrap();
|
||||||
|
repo.delete(&user.id, &thought.id).await.unwrap();
|
||||||
|
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
crates/adapters/postgres/src/notification.rs
Normal file
91
crates/adapters/postgres/src/notification.rs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use domain::{errors::DomainError, models::{feed::{PageParams, Paginated}, notification::{Notification, NotificationType}}, ports::NotificationRepository, value_objects::{NotificationId, ThoughtId, UserId}};
|
||||||
|
|
||||||
|
pub struct PgNotificationRepository { pool: PgPool }
|
||||||
|
impl PgNotificationRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl NotificationRepository for PgNotificationRepository {
|
||||||
|
async fn save(&self, n: &Notification) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO notifications(id,user_id,type,from_user_id,thought_id,read,created_at) VALUES($1,$2,$3,$4,$5,$6,$7)"
|
||||||
|
)
|
||||||
|
.bind(n.id.as_uuid()).bind(n.user_id.as_uuid()).bind(n.notification_type.as_str())
|
||||||
|
.bind(n.from_user_id.as_ref().map(|u| u.as_uuid()))
|
||||||
|
.bind(n.thought_id.as_ref().map(|t| t.as_uuid()))
|
||||||
|
.bind(n.read).bind(n.created_at)
|
||||||
|
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_for_user(&self, user_id: &UserId, page: &PageParams) -> Result<Paginated<Notification>, DomainError> {
|
||||||
|
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM notifications WHERE user_id=$1")
|
||||||
|
.bind(user_id.as_uuid()).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row { id: uuid::Uuid, user_id: uuid::Uuid, r#type: String, from_user_id: Option<uuid::Uuid>, thought_id: Option<uuid::Uuid>, read: bool, created_at: DateTime<Utc> }
|
||||||
|
let rows = sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT id,user_id,type,from_user_id,thought_id,read,created_at FROM notifications 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 items = rows.into_iter().map(|r| Notification {
|
||||||
|
id: NotificationId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id),
|
||||||
|
notification_type: NotificationType::from_str(&r.r#type),
|
||||||
|
from_user_id: r.from_user_id.map(UserId::from_uuid),
|
||||||
|
thought_id: r.thought_id.map(ThoughtId::from_uuid),
|
||||||
|
read: r.read, created_at: r.created_at,
|
||||||
|
}).collect();
|
||||||
|
Ok(Paginated { items, total, page: page.page, per_page: page.per_page })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mark_read(&self, id: &NotificationId, user_id: &UserId) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("UPDATE notifications SET read=true 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())).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mark_all_read(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("UPDATE notifications SET read=true WHERE user_id=$1")
|
||||||
|
.bind(user_id.as_uuid())
|
||||||
|
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::Utc;
|
||||||
|
use domain::{models::{notification::NotificationType, user::User}, value_objects::*};
|
||||||
|
use crate::user::PgUserRepository;
|
||||||
|
use domain::ports::UserRepository;
|
||||||
|
|
||||||
|
async fn seed_user(pool: &sqlx::PgPool) -> User {
|
||||||
|
let repo = PgUserRepository::new(pool.clone());
|
||||||
|
let u = User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into()));
|
||||||
|
repo.save(&u).await.unwrap(); u
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn save_and_list(pool: sqlx::PgPool) {
|
||||||
|
let user = seed_user(&pool).await;
|
||||||
|
let repo = PgNotificationRepository::new(pool);
|
||||||
|
use domain::models::feed::PageParams;
|
||||||
|
let n = Notification { id: NotificationId::new(), user_id: user.id.clone(), notification_type: NotificationType::Like, from_user_id: None, thought_id: None, read: false, created_at: Utc::now() };
|
||||||
|
repo.save(&n).await.unwrap();
|
||||||
|
let page = repo.list_for_user(&user.id, &PageParams { page: 1, per_page: 20 }).await.unwrap();
|
||||||
|
assert_eq!(page.total, 1);
|
||||||
|
assert!(!page.items[0].read);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn mark_all_read(pool: sqlx::PgPool) {
|
||||||
|
let user = seed_user(&pool).await;
|
||||||
|
let repo = PgNotificationRepository::new(pool);
|
||||||
|
use domain::models::feed::PageParams;
|
||||||
|
let n = Notification { id: NotificationId::new(), user_id: user.id.clone(), notification_type: NotificationType::Follow, from_user_id: None, thought_id: None, read: false, created_at: Utc::now() };
|
||||||
|
repo.save(&n).await.unwrap();
|
||||||
|
repo.mark_all_read(&user.id).await.unwrap();
|
||||||
|
let page = repo.list_for_user(&user.id, &PageParams { page: 1, per_page: 20 }).await.unwrap();
|
||||||
|
assert!(page.items[0].read);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
crates/adapters/postgres/src/remote_actor.rs
Normal file
33
crates/adapters/postgres/src/remote_actor.rs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use domain::{errors::DomainError, models::remote_actor::RemoteActor, ports::RemoteActorRepository};
|
||||||
|
|
||||||
|
pub struct PgRemoteActorRepository { pool: PgPool }
|
||||||
|
impl PgRemoteActorRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl RemoteActorRepository for PgRemoteActorRepository {
|
||||||
|
async fn upsert(&self, a: &RemoteActor) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO remote_actors(url,handle,display_name,inbox_url,shared_inbox_url,public_key,last_fetched_at)
|
||||||
|
VALUES($1,$2,$3,$4,$5,$6,$7)
|
||||||
|
ON CONFLICT(url) DO UPDATE SET handle=EXCLUDED.handle,display_name=EXCLUDED.display_name,
|
||||||
|
inbox_url=EXCLUDED.inbox_url,shared_inbox_url=EXCLUDED.shared_inbox_url,
|
||||||
|
public_key=EXCLUDED.public_key,last_fetched_at=EXCLUDED.last_fetched_at"
|
||||||
|
)
|
||||||
|
.bind(&a.url).bind(&a.handle).bind(&a.display_name).bind(&a.inbox_url)
|
||||||
|
.bind(&a.shared_inbox_url).bind(&a.public_key).bind(a.last_fetched_at)
|
||||||
|
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_url(&self, url: &str) -> Result<Option<RemoteActor>, DomainError> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row { url: String, handle: String, display_name: Option<String>, inbox_url: String, shared_inbox_url: Option<String>, public_key: String, last_fetched_at: DateTime<Utc> }
|
||||||
|
sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT url,handle,display_name,inbox_url,shared_inbox_url,public_key,last_fetched_at FROM remote_actors WHERE url=$1"
|
||||||
|
).bind(url).fetch_optional(&self.pool).await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
.map(|o| o.map(|r| RemoteActor { url: r.url, handle: r.handle, display_name: r.display_name, inbox_url: r.inbox_url, shared_inbox_url: r.shared_inbox_url, public_key: r.public_key, last_fetched_at: r.last_fetched_at }))
|
||||||
|
}
|
||||||
|
}
|
||||||
87
crates/adapters/postgres/src/tag.rs
Normal file
87
crates/adapters/postgres/src/tag.rs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use domain::{errors::DomainError, models::{feed::{PageParams, Paginated}, tag::Tag, thought::Thought}, ports::TagRepository, value_objects::ThoughtId};
|
||||||
|
|
||||||
|
pub struct PgTagRepository { pool: PgPool }
|
||||||
|
impl PgTagRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TagRepository for PgTagRepository {
|
||||||
|
async fn find_or_create(&self, name: &str) -> Result<Tag, DomainError> {
|
||||||
|
let name = name.to_lowercase();
|
||||||
|
sqlx::query("INSERT INTO tags(name) VALUES($1) ON CONFLICT(name) DO NOTHING")
|
||||||
|
.bind(&name).execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
#[derive(sqlx::FromRow)] struct Row { id: i32, name: String }
|
||||||
|
let row = sqlx::query_as::<_, Row>("SELECT id,name FROM tags WHERE name=$1").bind(&name)
|
||||||
|
.fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
Ok(Tag { id: row.id, name: row.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn attach_to_thought(&self, thought_id: &ThoughtId, tag_id: i32) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("INSERT INTO thought_tags(thought_id,tag_id) VALUES($1,$2) ON CONFLICT DO NOTHING")
|
||||||
|
.bind(thought_id.as_uuid()).bind(tag_id)
|
||||||
|
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn detach_from_thought(&self, thought_id: &ThoughtId) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("DELETE FROM thought_tags WHERE thought_id=$1").bind(thought_id.as_uuid())
|
||||||
|
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_for_thought(&self, thought_id: &ThoughtId) -> Result<Vec<Tag>, DomainError> {
|
||||||
|
#[derive(sqlx::FromRow)] struct Row { id: i32, name: String }
|
||||||
|
sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT t.id,t.name FROM tags t JOIN thought_tags tt ON tt.tag_id=t.id WHERE tt.thought_id=$1"
|
||||||
|
).bind(thought_id.as_uuid()).fetch_all(&self.pool).await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
.map(|rows| rows.into_iter().map(|r| Tag { id: r.id, name: r.name }).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_thoughts_by_tag(&self, tag_name: &str, page: &PageParams) -> Result<Paginated<Thought>, DomainError> {
|
||||||
|
let total: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM thought_tags tt JOIN tags t ON t.id=tt.tag_id WHERE t.name=$1"
|
||||||
|
).bind(tag_name).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, crate::thought::ThoughtRow>(
|
||||||
|
"SELECT th.id,th.user_id,th.content,th.in_reply_to_id,th.in_reply_to_url,th.ap_id,th.visibility,th.content_warning,th.sensitive,th.local,th.created_at,th.updated_at
|
||||||
|
FROM thoughts th JOIN thought_tags tt ON tt.thought_id=th.id JOIN tags t ON t.id=tt.tag_id
|
||||||
|
WHERE t.name=$1 ORDER BY th.created_at DESC LIMIT $2 OFFSET $3"
|
||||||
|
).bind(tag_name).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(Thought::from).collect(), 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::{thought::PgThoughtRepository, user::PgUserRepository};
|
||||||
|
use domain::ports::{ThoughtRepository, UserRepository};
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn find_or_create_tag(pool: sqlx::PgPool) {
|
||||||
|
let repo = PgTagRepository::new(pool);
|
||||||
|
let t1 = repo.find_or_create("rust").await.unwrap();
|
||||||
|
let t2 = repo.find_or_create("rust").await.unwrap();
|
||||||
|
assert_eq!(t1.id, t2.id);
|
||||||
|
assert_eq!(t1.name, "rust");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn attach_and_list(pool: sqlx::PgPool) {
|
||||||
|
let urepo = PgUserRepository::new(pool.clone());
|
||||||
|
let trepo = PgThoughtRepository::new(pool.clone());
|
||||||
|
let u = User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into()));
|
||||||
|
urepo.save(&u).await.unwrap();
|
||||||
|
let t = Thought::new_local(ThoughtId::new(), u.id.clone(), Content::new_local("hi").unwrap(), None, Visibility::Public, None, false);
|
||||||
|
trepo.save(&t).await.unwrap();
|
||||||
|
let repo = PgTagRepository::new(pool);
|
||||||
|
let tag = repo.find_or_create("greetings").await.unwrap();
|
||||||
|
repo.attach_to_thought(&t.id, tag.id).await.unwrap();
|
||||||
|
let tags = repo.list_for_thought(&t.id).await.unwrap();
|
||||||
|
assert_eq!(tags.len(), 1);
|
||||||
|
assert_eq!(tags[0].name, "greetings");
|
||||||
|
}
|
||||||
|
}
|
||||||
236
crates/adapters/postgres/src/thought.rs
Normal file
236
crates/adapters/postgres/src/thought.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
crates/adapters/postgres/src/top_friend.rs
Normal file
95
crates/adapters/postgres/src/top_friend.rs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use domain::{errors::DomainError, models::{top_friend::TopFriend, user::User}, ports::TopFriendRepository, value_objects::UserId};
|
||||||
|
|
||||||
|
pub struct PgTopFriendRepository { pool: PgPool }
|
||||||
|
impl PgTopFriendRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TopFriendRepository for PgTopFriendRepository {
|
||||||
|
async fn set_top_friends(&self, user_id: &UserId, friends: Vec<(UserId, i16)>) -> Result<(), DomainError> {
|
||||||
|
let mut tx = self.pool.begin().await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
sqlx::query("DELETE FROM top_friends WHERE user_id=$1")
|
||||||
|
.bind(user_id.as_uuid()).execute(&mut *tx).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
for (friend_id, pos) in friends {
|
||||||
|
sqlx::query("INSERT INTO top_friends(user_id,friend_id,position) VALUES($1,$2,$3)")
|
||||||
|
.bind(user_id.as_uuid()).bind(friend_id.as_uuid()).bind(pos)
|
||||||
|
.execute(&mut *tx).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
}
|
||||||
|
tx.commit().await.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<(TopFriend, User)>, DomainError> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct Row {
|
||||||
|
tf_user_id: uuid::Uuid, friend_id: uuid::Uuid, position: i16,
|
||||||
|
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: chrono::DateTime<chrono::Utc>, updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
let rows = sqlx::query_as::<_, Row>(
|
||||||
|
"SELECT tf.user_id AS tf_user_id, tf.friend_id, tf.position,
|
||||||
|
u.id, u.username, u.email, u.password_hash, u.display_name, u.bio,
|
||||||
|
u.avatar_url, u.header_url, u.custom_css, u.local, u.ap_id, u.inbox_url,
|
||||||
|
u.public_key, u.private_key, u.created_at, u.updated_at
|
||||||
|
FROM top_friends tf JOIN users u ON u.id=tf.friend_id
|
||||||
|
WHERE tf.user_id=$1 ORDER BY tf.position"
|
||||||
|
).bind(user_id.as_uuid()).fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(rows.into_iter().map(|r| {
|
||||||
|
use domain::value_objects::{Email, PasswordHash, Username};
|
||||||
|
let tf = TopFriend { user_id: UserId::from_uuid(r.tf_user_id), friend_id: UserId::from_uuid(r.friend_id), position: r.position };
|
||||||
|
let u = 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,
|
||||||
|
};
|
||||||
|
(tf, u)
|
||||||
|
}).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use domain::{models::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 set_and_list_top_friends(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 = PgTopFriendRepository::new(pool);
|
||||||
|
repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)]).await.unwrap();
|
||||||
|
let friends = repo.list_for_user(&alice.id).await.unwrap();
|
||||||
|
assert_eq!(friends.len(), 1);
|
||||||
|
assert_eq!(friends[0].0.position, 1);
|
||||||
|
assert_eq!(friends[0].1.username.as_str(), "bob");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn replace_top_friends(pool: sqlx::PgPool) {
|
||||||
|
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
|
||||||
|
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
|
||||||
|
let carol = seed_user(&pool, "carol", "carol@ex.com").await;
|
||||||
|
let repo = PgTopFriendRepository::new(pool);
|
||||||
|
repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)]).await.unwrap();
|
||||||
|
repo.set_top_friends(&alice.id, vec![(carol.id.clone(), 1)]).await.unwrap();
|
||||||
|
let friends = repo.list_for_user(&alice.id).await.unwrap();
|
||||||
|
assert_eq!(friends.len(), 1);
|
||||||
|
assert_eq!(friends[0].1.username.as_str(), "carol");
|
||||||
|
}
|
||||||
|
}
|
||||||
237
crates/adapters/postgres/src/user.rs
Normal file
237
crates/adapters/postgres/src/user.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
9
crates/api-types/Cargo.toml
Normal file
9
crates/api-types/Cargo.toml
Normal 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 }
|
||||||
2
crates/api-types/src/lib.rs
Normal file
2
crates/api-types/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod requests;
|
||||||
|
pub mod responses;
|
||||||
71
crates/api-types/src/requests.rs
Normal file
71
crates/api-types/src/requests.rs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RegisterRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateThoughtRequest {
|
||||||
|
pub content: String,
|
||||||
|
pub in_reply_to_id: Option<Uuid>,
|
||||||
|
pub visibility: Option<String>,
|
||||||
|
pub content_warning: Option<String>,
|
||||||
|
pub sensitive: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct EditThoughtRequest {
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
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)]
|
||||||
|
pub struct SetTopFriendsRequest {
|
||||||
|
pub friend_ids: Vec<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateApiKeyRequest {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
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)]
|
||||||
|
pub struct SearchQuery {
|
||||||
|
pub q: String,
|
||||||
|
pub page: Option<u64>,
|
||||||
|
pub per_page: Option<u64>,
|
||||||
|
}
|
||||||
69
crates/api-types/src/responses.rs
Normal file
69
crates/api-types/src/responses.rs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::Serialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct AuthResponse {
|
||||||
|
pub token: String,
|
||||||
|
pub user: UserResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
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)]
|
||||||
|
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)]
|
||||||
|
pub struct PagedResponse<T: Serialize> {
|
||||||
|
pub items: Vec<T>,
|
||||||
|
pub total: i64,
|
||||||
|
pub page: u64,
|
||||||
|
pub per_page: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ApiKeyResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
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)]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
17
crates/application/Cargo.toml
Normal file
17
crates/application/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[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 }
|
||||||
|
sha2 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true, features = ["full"] }
|
||||||
|
domain = { workspace = true, features = ["test-helpers"] }
|
||||||
1
crates/application/src/lib.rs
Normal file
1
crates/application/src/lib.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod use_cases;
|
||||||
29
crates/application/src/use_cases/api_keys.rs
Normal file
29
crates/application/src/use_cases/api_keys.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::api_key::ApiKey,
|
||||||
|
ports::ApiKeyRepository,
|
||||||
|
value_objects::{ApiKeyId, UserId},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn list_api_keys(keys: &dyn ApiKeyRepository, user_id: &UserId) -> Result<Vec<ApiKey>, DomainError> {
|
||||||
|
keys.list_for_user(user_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_api_key(keys: &dyn ApiKeyRepository, user_id: &UserId, name: String) -> Result<(ApiKey, String), DomainError> {
|
||||||
|
let raw_key = uuid::Uuid::new_v4().to_string().replace('-', "");
|
||||||
|
let key_hash = sha256_hex(&raw_key);
|
||||||
|
let key = ApiKey { id: ApiKeyId::new(), user_id: user_id.clone(), key_hash, name, created_at: Utc::now() };
|
||||||
|
keys.save(&key).await?;
|
||||||
|
Ok((key, raw_key))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_api_key(keys: &dyn ApiKeyRepository, user_id: &UserId, key_id: &ApiKeyId) -> Result<(), DomainError> {
|
||||||
|
keys.delete(key_id, user_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha256_hex(s: &str) -> String {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let hash = Sha256::digest(s.as_bytes());
|
||||||
|
hex::encode(hash)
|
||||||
|
}
|
||||||
115
crates/application/src/use_cases/auth.rs
Normal file
115
crates/application/src/use_cases/auth.rs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::user::User,
|
||||||
|
ports::{AuthService, EventPublisher, PasswordHasher, UserRepository},
|
||||||
|
value_objects::{Email, UserId, Username},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct RegisterInput { pub username: String, pub email: String, pub password: String }
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct RegisterOutput { pub user: User, pub token: String }
|
||||||
|
|
||||||
|
pub async fn register(
|
||||||
|
users: &dyn UserRepository,
|
||||||
|
hasher: &dyn PasswordHasher,
|
||||||
|
auth: &dyn AuthService,
|
||||||
|
_events: &dyn EventPublisher,
|
||||||
|
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?;
|
||||||
|
let token = auth.generate_token(&user.id)?;
|
||||||
|
Ok(RegisterOutput { user, token: token.token })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LoginInput { pub email: String, pub password: String }
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct LoginOutput { pub user: User, pub token: String }
|
||||||
|
|
||||||
|
pub async fn login(
|
||||||
|
users: &dyn UserRepository,
|
||||||
|
hasher: &dyn PasswordHasher,
|
||||||
|
auth: &dyn AuthService,
|
||||||
|
input: LoginInput,
|
||||||
|
) -> Result<LoginOutput, DomainError> {
|
||||||
|
let email = Email::new(input.email)?;
|
||||||
|
let user = users.find_by_email(&email).await?.ok_or(DomainError::Unauthorized)?;
|
||||||
|
if !hasher.verify(&input.password, &user.password_hash).await? {
|
||||||
|
return Err(DomainError::Unauthorized);
|
||||||
|
}
|
||||||
|
let token = auth.generate_token(&user.id)?;
|
||||||
|
Ok(LoginOutput { user, token: token.token })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
ports::{AuthService, GeneratedToken, PasswordHasher},
|
||||||
|
testing::{NoOpEventPublisher, TestStore},
|
||||||
|
value_objects::{PasswordHash, UserId},
|
||||||
|
};
|
||||||
|
|
||||||
|
struct FakeHasher;
|
||||||
|
#[async_trait] impl PasswordHasher for FakeHasher {
|
||||||
|
async fn hash(&self, plain: &str) -> Result<PasswordHash, DomainError> { Ok(PasswordHash(plain.to_string())) }
|
||||||
|
async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result<bool, DomainError> { Ok(plain == hash.0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FakeAuth;
|
||||||
|
impl AuthService for FakeAuth {
|
||||||
|
fn generate_token(&self, uid: &UserId) -> Result<GeneratedToken, DomainError> {
|
||||||
|
Ok(GeneratedToken { token: uid.to_string(), user_id: uid.clone() })
|
||||||
|
}
|
||||||
|
fn validate_token(&self, token: &str) -> Result<UserId, DomainError> {
|
||||||
|
Ok(UserId::from_uuid(uuid::Uuid::parse_str(token).map_err(|_| DomainError::Unauthorized)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn input() -> RegisterInput {
|
||||||
|
RegisterInput { username: "alice".into(), email: "alice@ex.com".into(), password: "pw".into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn register_creates_user() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let out = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()).await.unwrap();
|
||||||
|
assert_eq!(out.user.username.as_str(), "alice");
|
||||||
|
assert!(!out.token.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn register_rejects_duplicate_username() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()).await.unwrap();
|
||||||
|
let err = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()).await.unwrap_err();
|
||||||
|
assert!(matches!(err, DomainError::Conflict(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn login_succeeds_with_correct_password() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()).await.unwrap();
|
||||||
|
let out = login(&store, &FakeHasher, &FakeAuth, LoginInput { email: "alice@ex.com".into(), password: "pw".into() }).await.unwrap();
|
||||||
|
assert!(!out.token.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn login_fails_wrong_password() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()).await.unwrap();
|
||||||
|
let err = login(&store, &FakeHasher, &FakeAuth, LoginInput { email: "alice@ex.com".into(), password: "wrong".into() }).await.unwrap_err();
|
||||||
|
assert!(matches!(err, DomainError::Unauthorized));
|
||||||
|
}
|
||||||
|
}
|
||||||
43
crates/application/src/use_cases/feed.rs
Normal file
43
crates/application/src/use_cases/feed.rs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::{
|
||||||
|
feed::{FeedEntry, PageParams, Paginated, UserSummary},
|
||||||
|
thought::Thought,
|
||||||
|
user::User,
|
||||||
|
},
|
||||||
|
ports::{FeedRepository, FollowRepository, TagRepository, ThoughtRepository, UserRepository},
|
||||||
|
value_objects::UserId,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn get_home_feed(feed: &dyn FeedRepository, follows: &dyn FollowRepository, user_id: &UserId, page: PageParams) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
let following_ids = follows.get_accepted_following_ids(user_id).await?;
|
||||||
|
feed.home_feed(&following_ids, &page, Some(user_id)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_public_feed(feed: &dyn FeedRepository, viewer_id: Option<&UserId>, page: PageParams) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
feed.public_feed(&page, viewer_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_feed(thoughts: &dyn ThoughtRepository, user_id: &UserId, page: PageParams) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
thoughts.list_by_user(user_id, &page).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_followers(follows: &dyn FollowRepository, user_id: &UserId, page: PageParams) -> Result<Paginated<User>, DomainError> {
|
||||||
|
follows.list_followers(user_id, &page).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_following(follows: &dyn FollowRepository, user_id: &UserId, page: PageParams) -> Result<Paginated<User>, DomainError> {
|
||||||
|
follows.list_following(user_id, &page).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_by_tag(tags: &dyn TagRepository, tag_name: &str, page: PageParams) -> Result<Paginated<Thought>, DomainError> {
|
||||||
|
tags.list_thoughts_by_tag(tag_name, &page).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search(feed: &dyn FeedRepository, query: &str, page: PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
feed.search(query, &page, viewer_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_users(users: &dyn UserRepository) -> Result<Vec<UserSummary>, DomainError> {
|
||||||
|
users.list_with_stats().await
|
||||||
|
}
|
||||||
6
crates/application/src/use_cases/mod.rs
Normal file
6
crates/application/src/use_cases/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod api_keys;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod feed;
|
||||||
|
pub mod profile;
|
||||||
|
pub mod social;
|
||||||
|
pub mod thoughts;
|
||||||
37
crates/application/src/use_cases/profile.rs
Normal file
37
crates/application/src/use_cases/profile.rs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::{top_friend::TopFriend, user::User},
|
||||||
|
ports::{TopFriendRepository, UserRepository},
|
||||||
|
value_objects::{UserId, Username},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn get_user(users: &dyn UserRepository, user_id: &UserId) -> Result<User, DomainError> {
|
||||||
|
users.find_by_id(user_id).await?.ok_or(DomainError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_by_username(users: &dyn UserRepository, username: &str) -> Result<User, DomainError> {
|
||||||
|
let username = Username::from_trusted(username.to_string());
|
||||||
|
users.find_by_username(&username).await?.ok_or(DomainError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_profile(
|
||||||
|
users: &dyn UserRepository,
|
||||||
|
user_id: &UserId,
|
||||||
|
display_name: Option<String>,
|
||||||
|
bio: Option<String>,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
header_url: Option<String>,
|
||||||
|
custom_css: Option<String>,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
users.update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_top_friends(top_friends: &dyn TopFriendRepository, user_id: &UserId) -> Result<Vec<(TopFriend, User)>, DomainError> {
|
||||||
|
top_friends.list_for_user(user_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_top_friends(top_friends: &dyn TopFriendRepository, user_id: &UserId, friend_ids: Vec<UserId>) -> Result<(), DomainError> {
|
||||||
|
if friend_ids.len() > 8 { return Err(DomainError::InvalidInput("top friends: max 8".into())); }
|
||||||
|
let friends: Vec<(UserId, i16)> = friend_ids.into_iter().enumerate().map(|(i, id)| (id, (i + 1) as i16)).collect();
|
||||||
|
top_friends.set_top_friends(user_id, friends).await
|
||||||
|
}
|
||||||
117
crates/application/src/use_cases/social.rs
Normal file
117
crates/application/src/use_cases/social.rs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
|
models::social::{Block, Boost, Follow, FollowState, Like},
|
||||||
|
ports::{BlockRepository, BoostRepository, EventPublisher, FollowRepository, LikeRepository},
|
||||||
|
value_objects::{BoostId, LikeId, ThoughtId, UserId},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn like_thought(likes: &dyn LikeRepository, events: &dyn EventPublisher, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> {
|
||||||
|
let like = Like { id: LikeId::new(), user_id: user_id.clone(), thought_id: thought_id.clone(), ap_id: None, created_at: Utc::now() };
|
||||||
|
likes.save(&like).await?;
|
||||||
|
events.publish(&DomainEvent::LikeAdded { like_id: like.id, user_id: user_id.clone(), thought_id: thought_id.clone() }).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unlike_thought(likes: &dyn LikeRepository, events: &dyn EventPublisher, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> {
|
||||||
|
likes.delete(user_id, thought_id).await?;
|
||||||
|
events.publish(&DomainEvent::LikeRemoved { user_id: user_id.clone(), thought_id: thought_id.clone() }).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn boost_thought(boosts: &dyn BoostRepository, events: &dyn EventPublisher, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> {
|
||||||
|
let boost = Boost { id: BoostId::new(), user_id: user_id.clone(), thought_id: thought_id.clone(), ap_id: None, created_at: Utc::now() };
|
||||||
|
boosts.save(&boost).await?;
|
||||||
|
events.publish(&DomainEvent::BoostAdded { boost_id: boost.id, user_id: user_id.clone(), thought_id: thought_id.clone() }).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unboost_thought(boosts: &dyn BoostRepository, events: &dyn EventPublisher, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> {
|
||||||
|
boosts.delete(user_id, thought_id).await?;
|
||||||
|
events.publish(&DomainEvent::BoostRemoved { user_id: user_id.clone(), thought_id: thought_id.clone() }).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn follow_user(follows: &dyn FollowRepository, events: &dyn EventPublisher, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> {
|
||||||
|
if follower_id == following_id { return Err(DomainError::InvalidInput("cannot follow yourself".into())); }
|
||||||
|
let follow = Follow { follower_id: follower_id.clone(), following_id: following_id.clone(), state: FollowState::Accepted, ap_id: None, created_at: Utc::now() };
|
||||||
|
follows.save(&follow).await?;
|
||||||
|
events.publish(&DomainEvent::FollowAccepted { follower_id: follower_id.clone(), following_id: following_id.clone() }).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unfollow_user(follows: &dyn FollowRepository, events: &dyn EventPublisher, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> {
|
||||||
|
follows.delete(follower_id, following_id).await?;
|
||||||
|
events.publish(&DomainEvent::Unfollowed { follower_id: follower_id.clone(), following_id: following_id.clone() }).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn accept_follow(follows: &dyn FollowRepository, events: &dyn EventPublisher, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> {
|
||||||
|
follows.update_state(follower_id, following_id, &FollowState::Accepted).await?;
|
||||||
|
events.publish(&DomainEvent::FollowAccepted { follower_id: follower_id.clone(), following_id: following_id.clone() }).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reject_follow(follows: &dyn FollowRepository, events: &dyn EventPublisher, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> {
|
||||||
|
follows.update_state(follower_id, following_id, &FollowState::Rejected).await?;
|
||||||
|
events.publish(&DomainEvent::FollowRejected { follower_id: follower_id.clone(), following_id: following_id.clone() }).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn block_user(blocks: &dyn BlockRepository, events: &dyn EventPublisher, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError> {
|
||||||
|
if blocker_id == blocked_id { return Err(DomainError::InvalidInput("cannot block yourself".into())); }
|
||||||
|
let block = Block { blocker_id: blocker_id.clone(), blocked_id: blocked_id.clone(), created_at: Utc::now() };
|
||||||
|
blocks.save(&block).await?;
|
||||||
|
events.publish(&DomainEvent::UserBlocked { blocker_id: blocker_id.clone(), blocked_id: blocked_id.clone() }).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unblock_user(blocks: &dyn BlockRepository, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError> {
|
||||||
|
blocks.delete(blocker_id, blocked_id).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use domain::{
|
||||||
|
models::{thought::{Thought, Visibility}, user::User},
|
||||||
|
testing::TestStore,
|
||||||
|
value_objects::*,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn user(name: &str) -> User {
|
||||||
|
User::new_local(UserId::new(), Username::new(name).unwrap(), Email::new(format!("{name}@ex.com")).unwrap(), PasswordHash("h".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn like_and_unlike() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = user("alice");
|
||||||
|
let tid = ThoughtId::new();
|
||||||
|
store.thoughts.lock().unwrap().push(Thought::new_local(tid.clone(), alice.id.clone(), Content::new_local("hi").unwrap(), None, Visibility::Public, None, false));
|
||||||
|
like_thought(&store, &store, &alice.id, &tid).await.unwrap();
|
||||||
|
assert_eq!(store.likes.lock().unwrap().len(), 1);
|
||||||
|
unlike_thought(&store, &store, &alice.id, &tid).await.unwrap();
|
||||||
|
assert!(store.likes.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn follow_and_unfollow() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = user("alice"); let bob = user("bob");
|
||||||
|
follow_user(&store, &store, &alice.id, &bob.id).await.unwrap();
|
||||||
|
assert_eq!(store.follows.lock().unwrap().len(), 1);
|
||||||
|
unfollow_user(&store, &store, &alice.id, &bob.id).await.unwrap();
|
||||||
|
assert!(store.follows.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cannot_follow_self() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = user("alice");
|
||||||
|
let err = follow_user(&store, &store, &alice.id, &alice.id).await.unwrap_err();
|
||||||
|
assert!(matches!(err, DomainError::InvalidInput(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
122
crates/application/src/use_cases/thoughts.rs
Normal file
122
crates/application/src/use_cases/thoughts.rs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
|
models::thought::{Thought, Visibility},
|
||||||
|
ports::{EventPublisher, ThoughtRepository, UserRepository},
|
||||||
|
value_objects::{Content, ThoughtId, UserId},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct CreateThoughtInput {
|
||||||
|
pub user_id: UserId,
|
||||||
|
pub content: String,
|
||||||
|
pub in_reply_to_id: Option<ThoughtId>,
|
||||||
|
pub visibility: Option<String>,
|
||||||
|
pub content_warning: Option<String>,
|
||||||
|
pub sensitive: bool,
|
||||||
|
}
|
||||||
|
pub struct CreateThoughtOutput { pub thought: Thought }
|
||||||
|
|
||||||
|
pub async fn create_thought(
|
||||||
|
thoughts: &dyn ThoughtRepository,
|
||||||
|
_users: &dyn UserRepository,
|
||||||
|
events: &dyn EventPublisher,
|
||||||
|
input: CreateThoughtInput,
|
||||||
|
) -> Result<CreateThoughtOutput, DomainError> {
|
||||||
|
let content = Content::new_local(input.content)?;
|
||||||
|
let visibility = input.visibility.as_deref().map(Visibility::from_str).unwrap_or(Visibility::Public);
|
||||||
|
let thought = Thought::new_local(
|
||||||
|
ThoughtId::new(), input.user_id,
|
||||||
|
content, input.in_reply_to_id.clone(),
|
||||||
|
visibility, input.content_warning, input.sensitive,
|
||||||
|
);
|
||||||
|
thoughts.save(&thought).await?;
|
||||||
|
events.publish(&DomainEvent::ThoughtCreated {
|
||||||
|
thought_id: thought.id.clone(),
|
||||||
|
user_id: thought.user_id.clone(),
|
||||||
|
in_reply_to_id: input.in_reply_to_id,
|
||||||
|
}).await?;
|
||||||
|
Ok(CreateThoughtOutput { thought })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_thought(
|
||||||
|
thoughts: &dyn ThoughtRepository,
|
||||||
|
events: &dyn EventPublisher,
|
||||||
|
id: &ThoughtId,
|
||||||
|
user_id: &UserId,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
let thought = thoughts.find_by_id(id).await?.ok_or(DomainError::NotFound)?;
|
||||||
|
if thought.user_id != *user_id { return Err(DomainError::NotFound); }
|
||||||
|
thoughts.delete(id, user_id).await?;
|
||||||
|
events.publish(&DomainEvent::ThoughtDeleted { thought_id: id.clone(), user_id: user_id.clone() }).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn edit_thought(
|
||||||
|
thoughts: &dyn ThoughtRepository,
|
||||||
|
events: &dyn EventPublisher,
|
||||||
|
id: &ThoughtId,
|
||||||
|
user_id: &UserId,
|
||||||
|
new_content: String,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
let thought = thoughts.find_by_id(id).await?.ok_or(DomainError::NotFound)?;
|
||||||
|
if thought.user_id != *user_id { return Err(DomainError::NotFound); }
|
||||||
|
let content = Content::new_local(new_content)?;
|
||||||
|
thoughts.update_content(id, &content).await?;
|
||||||
|
events.publish(&DomainEvent::ThoughtUpdated { thought_id: id.clone(), user_id: user_id.clone() }).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_thought(thoughts: &dyn ThoughtRepository, id: &ThoughtId) -> Result<Thought, DomainError> {
|
||||||
|
thoughts.find_by_id(id).await?.ok_or(DomainError::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_thread(thoughts: &dyn ThoughtRepository, id: &ThoughtId) -> Result<Vec<Thought>, DomainError> {
|
||||||
|
thoughts.get_thread(id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use domain::{
|
||||||
|
models::user::User,
|
||||||
|
testing::{NoOpEventPublisher, TestStore},
|
||||||
|
value_objects::*,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn user() -> User {
|
||||||
|
User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn input(uid: UserId) -> CreateThoughtInput {
|
||||||
|
CreateThoughtInput { user_id: uid, content: "hello".into(), in_reply_to_id: None, visibility: None, content_warning: None, sensitive: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_thought_saves_and_emits_event() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let u = user(); store.users.lock().unwrap().push(u.clone());
|
||||||
|
let out = create_thought(&store, &store, &store, input(u.id.clone())).await.unwrap();
|
||||||
|
assert_eq!(out.thought.content.as_str(), "hello");
|
||||||
|
assert_eq!(store.events.lock().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_own_thought_succeeds() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let u = user(); store.users.lock().unwrap().push(u.clone());
|
||||||
|
let out = create_thought(&store, &store, &NoOpEventPublisher, input(u.id.clone())).await.unwrap();
|
||||||
|
delete_thought(&store, &NoOpEventPublisher, &out.thought.id, &u.id).await.unwrap();
|
||||||
|
assert!(store.thoughts.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_other_thought_returns_not_found() {
|
||||||
|
let store = TestStore::default();
|
||||||
|
let alice = user();
|
||||||
|
let bob = User::new_local(UserId::new(), Username::new("bob").unwrap(), Email::new("bob@ex.com").unwrap(), PasswordHash("h".into()));
|
||||||
|
store.users.lock().unwrap().extend([alice.clone(), bob.clone()]);
|
||||||
|
let out = create_thought(&store, &store, &NoOpEventPublisher, input(alice.id.clone())).await.unwrap();
|
||||||
|
let err = delete_thought(&store, &NoOpEventPublisher, &out.thought.id, &bob.id).await.unwrap_err();
|
||||||
|
assert!(matches!(err, DomainError::NotFound));
|
||||||
|
}
|
||||||
|
}
|
||||||
18
crates/domain/Cargo.toml
Normal file
18
crates/domain/Cargo.toml
Normal 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"] }
|
||||||
17
crates/domain/src/errors.rs
Normal file
17
crates/domain/src/errors.rs
Normal 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),
|
||||||
|
}
|
||||||
28
crates/domain/src/events.rs
Normal file
28
crates/domain/src/events.rs
Normal 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
8
crates/domain/src/lib.rs
Normal 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;
|
||||||
11
crates/domain/src/models/api_key.rs
Normal file
11
crates/domain/src/models/api_key.rs
Normal 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>,
|
||||||
|
}
|
||||||
40
crates/domain/src/models/feed.rs
Normal file
40
crates/domain/src/models/feed.rs
Normal 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,
|
||||||
|
}
|
||||||
9
crates/domain/src/models/mod.rs
Normal file
9
crates/domain/src/models/mod.rs
Normal 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;
|
||||||
24
crates/domain/src/models/notification.rs
Normal file
24
crates/domain/src/models/notification.rs
Normal 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>,
|
||||||
|
}
|
||||||
12
crates/domain/src/models/remote_actor.rs
Normal file
12
crates/domain/src/models/remote_actor.rs
Normal 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>,
|
||||||
|
}
|
||||||
47
crates/domain/src/models/social.rs
Normal file
47
crates/domain/src/models/social.rs
Normal 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>,
|
||||||
|
}
|
||||||
2
crates/domain/src/models/tag.rs
Normal file
2
crates/domain/src/models/tag.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Tag { pub id: i32, pub name: String }
|
||||||
45
crates/domain/src/models/thought.rs
Normal file
45
crates/domain/src/models/thought.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
crates/domain/src/models/top_friend.rs
Normal file
4
crates/domain/src/models/top_friend.rs
Normal 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 }
|
||||||
35
crates/domain/src/models/user.rs
Normal file
35
crates/domain/src/models/user.rs
Normal 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
138
crates/domain/src/ports.rs
Normal 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>;
|
||||||
|
}
|
||||||
295
crates/domain/src/testing.rs
Normal file
295
crates/domain/src/testing.rs
Normal 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(()) }
|
||||||
|
}
|
||||||
117
crates/domain/src/value_objects.rs
Normal file
117
crates/domain/src/value_objects.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
34
crates/presentation/Cargo.toml
Normal file
34
crates/presentation/Cargo.toml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
[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 }
|
||||||
|
sqlx = { 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"] }
|
||||||
29
crates/presentation/src/errors.rs
Normal file
29
crates/presentation/src/errors.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use api_types::responses::ErrorResponse;
|
||||||
|
|
||||||
|
pub enum ApiError {
|
||||||
|
Domain(DomainError),
|
||||||
|
Unauthorized,
|
||||||
|
BadRequest(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DomainError> for ApiError {
|
||||||
|
fn from(e: DomainError) -> Self { Self::Domain(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for ApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, msg) = match self {
|
||||||
|
Self::Domain(DomainError::NotFound) => (StatusCode::NOT_FOUND, "not found".into()),
|
||||||
|
Self::Domain(DomainError::Unauthorized) => (StatusCode::UNAUTHORIZED, "unauthorized".into()),
|
||||||
|
Self::Domain(DomainError::Forbidden) => (StatusCode::FORBIDDEN, "forbidden".into()),
|
||||||
|
Self::Domain(DomainError::Conflict(m)) => (StatusCode::CONFLICT, m),
|
||||||
|
Self::Domain(DomainError::InvalidInput(m)) => (StatusCode::UNPROCESSABLE_ENTITY, m),
|
||||||
|
Self::Domain(DomainError::Internal(_)) => (StatusCode::INTERNAL_SERVER_ERROR, "internal server error".into()),
|
||||||
|
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".into()),
|
||||||
|
Self::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
|
||||||
|
};
|
||||||
|
(status, Json(ErrorResponse { error: msg })).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
47
crates/presentation/src/extractors.rs
Normal file
47
crates/presentation/src/extractors.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
use axum::{extract::FromRequestParts, http::request::Parts};
|
||||||
|
use domain::value_objects::UserId;
|
||||||
|
use crate::{errors::ApiError, state::AppState};
|
||||||
|
|
||||||
|
pub struct AuthUser(pub UserId);
|
||||||
|
pub struct OptionalAuthUser(pub Option<UserId>);
|
||||||
|
|
||||||
|
impl FromRequestParts<AppState> for AuthUser {
|
||||||
|
type Rejection = ApiError;
|
||||||
|
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, ApiError> {
|
||||||
|
extract_user_id(parts, state).await?
|
||||||
|
.ok_or(ApiError::Unauthorized)
|
||||||
|
.map(AuthUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRequestParts<AppState> for OptionalAuthUser {
|
||||||
|
type Rejection = ApiError;
|
||||||
|
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, ApiError> {
|
||||||
|
Ok(OptionalAuthUser(extract_user_id(parts, state).await?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn extract_user_id(parts: &mut Parts, state: &AppState) -> Result<Option<UserId>, ApiError> {
|
||||||
|
if let Some(auth_header) = parts.headers.get("Authorization") {
|
||||||
|
if let Ok(s) = auth_header.to_str() {
|
||||||
|
if let Some(token) = s.strip_prefix("Bearer ") {
|
||||||
|
return state.auth.validate_token(token).map(Some).map_err(|_| ApiError::Unauthorized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(key_header) = parts.headers.get("X-Api-Key") {
|
||||||
|
if let Ok(raw) = key_header.to_str() {
|
||||||
|
let hash = sha256_hex(raw);
|
||||||
|
if let Ok(Some(key)) = state.api_keys.find_by_hash(&hash).await {
|
||||||
|
return Ok(Some(key.user_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha256_hex(s: &str) -> String {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let hash = Sha256::digest(s.as_bytes());
|
||||||
|
hex::encode(hash)
|
||||||
|
}
|
||||||
19
crates/presentation/src/handlers/api_keys.rs
Normal file
19
crates/presentation/src/handlers/api_keys.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
use axum::{extract::{Path, State}, http::StatusCode, Json};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use api_types::{requests::CreateApiKeyRequest, responses::ApiKeyResponse};
|
||||||
|
use application::use_cases::api_keys::{create_api_key, delete_api_key, list_api_keys};
|
||||||
|
use domain::value_objects::ApiKeyId;
|
||||||
|
use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
|
||||||
|
|
||||||
|
pub async fn get_api_keys(State(s): State<AppState>, AuthUser(uid): AuthUser) -> Result<Json<Vec<ApiKeyResponse>>, ApiError> {
|
||||||
|
let keys = list_api_keys(&*s.api_keys, &uid).await?;
|
||||||
|
Ok(Json(keys.into_iter().map(|k| ApiKeyResponse { id: k.id.as_uuid(), name: k.name, created_at: k.created_at }).collect()))
|
||||||
|
}
|
||||||
|
pub async fn post_api_key(State(s): State<AppState>, AuthUser(uid): AuthUser, Json(body): Json<CreateApiKeyRequest>) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let (key, raw) = create_api_key(&*s.api_keys, &uid, body.name).await?;
|
||||||
|
Ok(Json(serde_json::json!({ "id": key.id.as_uuid(), "name": key.name, "key": raw })))
|
||||||
|
}
|
||||||
|
pub async fn delete_api_key_handler(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(id): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
||||||
|
delete_api_key(&*s.api_keys, &uid, &ApiKeyId::from_uuid(id)).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
35
crates/presentation/src/handlers/auth.rs
Normal file
35
crates/presentation/src/handlers/auth.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||||
|
use api_types::{requests::{LoginRequest, RegisterRequest}, responses::{AuthResponse, UserResponse}};
|
||||||
|
use application::use_cases::auth::{login, register, LoginInput, RegisterInput};
|
||||||
|
use crate::{errors::ApiError, state::AppState};
|
||||||
|
|
||||||
|
pub fn to_user_response(u: &domain::models::user::User) -> UserResponse {
|
||||||
|
UserResponse {
|
||||||
|
id: u.id.as_uuid(),
|
||||||
|
username: u.username.to_string(),
|
||||||
|
display_name: u.display_name.clone(),
|
||||||
|
bio: u.bio.clone(),
|
||||||
|
avatar_url: u.avatar_url.clone(),
|
||||||
|
header_url: u.header_url.clone(),
|
||||||
|
local: u.local,
|
||||||
|
created_at: u.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_register(State(s): State<AppState>, Json(body): Json<RegisterRequest>) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let out = register(&*s.users, &*s.hasher, &*s.auth, &*s.events, RegisterInput {
|
||||||
|
username: body.username,
|
||||||
|
email: body.email,
|
||||||
|
password: body.password,
|
||||||
|
}).await?;
|
||||||
|
let resp = AuthResponse { token: out.token, user: to_user_response(&out.user) };
|
||||||
|
Ok((StatusCode::CREATED, Json(resp)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_login(State(s): State<AppState>, Json(body): Json<LoginRequest>) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let out = login(&*s.users, &*s.hasher, &*s.auth, LoginInput {
|
||||||
|
email: body.email,
|
||||||
|
password: body.password,
|
||||||
|
}).await?;
|
||||||
|
Ok(Json(AuthResponse { token: out.token, user: to_user_response(&out.user) }))
|
||||||
|
}
|
||||||
38
crates/presentation/src/handlers/feed.rs
Normal file
38
crates/presentation/src/handlers/feed.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use axum::{extract::{Path, Query, State}, Json};
|
||||||
|
use api_types::requests::{PaginationQuery, SearchQuery};
|
||||||
|
use application::use_cases::feed::{get_home_feed, get_public_feed, get_followers, get_following, search};
|
||||||
|
use domain::models::feed::PageParams;
|
||||||
|
use crate::{errors::ApiError, extractors::{AuthUser, OptionalAuthUser}, handlers::auth::to_user_response, state::AppState};
|
||||||
|
use application::use_cases::profile::get_user_by_username;
|
||||||
|
|
||||||
|
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(|e| e.thought.id.as_uuid()).collect::<Vec<_>>(), "total": result.total, "page": result.page })))
|
||||||
|
}
|
||||||
|
|
||||||
|
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(|e| e.thought.id.as_uuid()).collect::<Vec<_>>(), "total": result.total, "page": result.page })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search_handler(State(s): State<AppState>, OptionalAuthUser(viewer): OptionalAuthUser, Query(q): Query<SearchQuery>) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let page = PageParams { page: q.page.unwrap_or(1), per_page: q.per_page.unwrap_or(20) };
|
||||||
|
let result = search(&*s.feed, &q.q, page, viewer.as_ref()).await?;
|
||||||
|
Ok(Json(serde_json::json!({ "items": result.items.iter().map(|e| e.thought.id.as_uuid()).collect::<Vec<_>>(), "total": result.total })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_following_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_following(&*s.follows, &user.id, page).await?;
|
||||||
|
Ok(Json(serde_json::json!({ "total": result.total, "items": result.items.iter().map(to_user_response).collect::<Vec<_>>() })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_followers_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_followers(&*s.follows, &user.id, page).await?;
|
||||||
|
Ok(Json(serde_json::json!({ "total": result.total, "items": result.items.iter().map(to_user_response).collect::<Vec<_>>() })))
|
||||||
|
}
|
||||||
7
crates/presentation/src/handlers/mod.rs
Normal file
7
crates/presentation/src/handlers/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
pub mod api_keys;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod feed;
|
||||||
|
pub mod notifications;
|
||||||
|
pub mod social;
|
||||||
|
pub mod thoughts;
|
||||||
|
pub mod users;
|
||||||
18
crates/presentation/src/handlers/notifications.rs
Normal file
18
crates/presentation/src/handlers/notifications.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
use axum::{extract::{Path, State}, http::StatusCode, Json};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use domain::{models::feed::PageParams, value_objects::NotificationId};
|
||||||
|
use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
|
||||||
|
|
||||||
|
pub async fn list_notifications(State(s): State<AppState>, AuthUser(uid): AuthUser) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let page = PageParams { page: 1, per_page: 20 };
|
||||||
|
let result = s.notifications.list_for_user(&uid, &page).await?;
|
||||||
|
Ok(Json(serde_json::json!({ "total": result.total, "unread": result.items.iter().filter(|n| !n.read).count() })))
|
||||||
|
}
|
||||||
|
pub async fn mark_notification_read(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(id): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
||||||
|
s.notifications.mark_read(&NotificationId::from_uuid(id), &uid).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
pub async fn mark_all_read(State(s): State<AppState>, AuthUser(uid): AuthUser) -> Result<StatusCode, ApiError> {
|
||||||
|
s.notifications.mark_all_read(&uid).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
51
crates/presentation/src/handlers/social.rs
Normal file
51
crates/presentation/src/handlers/social.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
use axum::{extract::{Path, State}, http::StatusCode, Json};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use api_types::requests::SetTopFriendsRequest;
|
||||||
|
use application::use_cases::social::*;
|
||||||
|
use application::use_cases::profile::{get_top_friends, set_top_friends, get_user_by_username};
|
||||||
|
use domain::value_objects::{ThoughtId, UserId};
|
||||||
|
use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
|
||||||
|
|
||||||
|
pub async fn post_like(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(id): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
||||||
|
like_thought(&*s.likes, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
pub async fn delete_like(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(id): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
||||||
|
unlike_thought(&*s.likes, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
pub async fn post_boost(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(id): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
||||||
|
boost_thought(&*s.boosts, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
pub async fn delete_boost(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(id): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
||||||
|
unboost_thought(&*s.boosts, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
pub async fn post_follow(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(target): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
||||||
|
follow_user(&*s.follows, &*s.events, &uid, &UserId::from_uuid(target)).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
pub async fn delete_follow(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(target): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
||||||
|
unfollow_user(&*s.follows, &*s.events, &uid, &UserId::from_uuid(target)).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
pub async fn post_block(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(target): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
||||||
|
block_user(&*s.blocks, &*s.events, &uid, &UserId::from_uuid(target)).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
pub async fn delete_block(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(target): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
||||||
|
unblock_user(&*s.blocks, &uid, &UserId::from_uuid(target)).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
pub async fn put_top_friends(State(s): State<AppState>, AuthUser(uid): AuthUser, Json(body): Json<SetTopFriendsRequest>) -> Result<StatusCode, ApiError> {
|
||||||
|
let ids: Vec<UserId> = body.friend_ids.into_iter().map(UserId::from_uuid).collect();
|
||||||
|
set_top_friends(&*s.top_friends, &uid, ids).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
pub async fn get_top_friends_handler(State(s): State<AppState>, Path(username): Path<String>) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let user = get_user_by_username(&*s.users, &username).await?;
|
||||||
|
let friends = get_top_friends(&*s.top_friends, &user.id).await?;
|
||||||
|
let ids: Vec<Uuid> = friends.iter().map(|(tf, _)| tf.friend_id.as_uuid()).collect();
|
||||||
|
Ok(Json(serde_json::json!({ "top_friends": ids })))
|
||||||
|
}
|
||||||
64
crates/presentation/src/handlers/thoughts.rs
Normal file
64
crates/presentation/src/handlers/thoughts.rs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
use axum::{extract::{Path, State}, http::StatusCode, response::IntoResponse, Json};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use api_types::requests::{CreateThoughtRequest, EditThoughtRequest};
|
||||||
|
use application::use_cases::thoughts::{create_thought, delete_thought, edit_thought, get_thought, get_thread, CreateThoughtInput};
|
||||||
|
use domain::value_objects::ThoughtId;
|
||||||
|
use crate::{errors::ApiError, extractors::{AuthUser, OptionalAuthUser}, handlers::auth::to_user_response, state::AppState};
|
||||||
|
|
||||||
|
fn thought_to_json(t: &domain::models::thought::Thought, author: &domain::models::user::User, like_count: i64, boost_count: i64, reply_count: i64) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"id": t.id.as_uuid(),
|
||||||
|
"content": t.content.as_str(),
|
||||||
|
"author": to_user_response(author),
|
||||||
|
"in_reply_to_id": t.in_reply_to_id.as_ref().map(|x| x.as_uuid()),
|
||||||
|
"visibility": t.visibility.as_str(),
|
||||||
|
"content_warning": t.content_warning,
|
||||||
|
"sensitive": t.sensitive,
|
||||||
|
"like_count": like_count,
|
||||||
|
"boost_count": boost_count,
|
||||||
|
"reply_count": reply_count,
|
||||||
|
"created_at": t.created_at,
|
||||||
|
"updated_at": t.updated_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_thought(State(s): State<AppState>, AuthUser(uid): AuthUser, Json(body): Json<CreateThoughtRequest>) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let in_reply_to = body.in_reply_to_id.map(ThoughtId::from_uuid);
|
||||||
|
let out = create_thought(&*s.thoughts, &*s.users, &*s.events, CreateThoughtInput {
|
||||||
|
user_id: uid.clone(),
|
||||||
|
content: body.content,
|
||||||
|
in_reply_to_id: in_reply_to,
|
||||||
|
visibility: body.visibility,
|
||||||
|
content_warning: body.content_warning,
|
||||||
|
sensitive: body.sensitive.unwrap_or(false),
|
||||||
|
}).await?;
|
||||||
|
let author = s.users.find_by_id(&uid).await?.ok_or(domain::errors::DomainError::NotFound)?;
|
||||||
|
Ok((StatusCode::CREATED, Json(thought_to_json(&out.thought, &author, 0, 0, 0))))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_thought_handler(State(s): State<AppState>, Path(id): Path<Uuid>, OptionalAuthUser(_viewer): OptionalAuthUser) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
let thought = get_thought(&*s.thoughts, &ThoughtId::from_uuid(id)).await?;
|
||||||
|
let author = s.users.find_by_id(&thought.user_id).await?.ok_or(domain::errors::DomainError::NotFound)?;
|
||||||
|
Ok(Json(thought_to_json(&thought, &author, 0, 0, 0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_thought_handler(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(id): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
||||||
|
delete_thought(&*s.thoughts, &*s.events, &ThoughtId::from_uuid(id), &uid).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn patch_thought(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(id): Path<Uuid>, Json(body): Json<EditThoughtRequest>) -> Result<StatusCode, ApiError> {
|
||||||
|
edit_thought(&*s.thoughts, &*s.events, &ThoughtId::from_uuid(id), &uid, body.content).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_thread_handler(State(s): State<AppState>, Path(id): Path<Uuid>) -> Result<Json<Vec<serde_json::Value>>, ApiError> {
|
||||||
|
let thoughts = get_thread(&*s.thoughts, &ThoughtId::from_uuid(id)).await?;
|
||||||
|
let mut items = Vec::new();
|
||||||
|
for t in &thoughts {
|
||||||
|
if let Ok(Some(author)) = s.users.find_by_id(&t.user_id).await {
|
||||||
|
items.push(thought_to_json(t, &author, 0, 0, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Json(items))
|
||||||
|
}
|
||||||
15
crates/presentation/src/handlers/users.rs
Normal file
15
crates/presentation/src/handlers/users.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
use axum::{extract::{Path, State}, Json};
|
||||||
|
use api_types::{requests::UpdateProfileRequest, responses::UserResponse};
|
||||||
|
use application::use_cases::profile::{get_user_by_username, update_profile};
|
||||||
|
use crate::{errors::ApiError, extractors::AuthUser, handlers::auth::to_user_response, state::AppState};
|
||||||
|
|
||||||
|
pub async fn get_user(State(s): State<AppState>, Path(username): Path<String>) -> Result<Json<UserResponse>, ApiError> {
|
||||||
|
let user = get_user_by_username(&*s.users, &username).await?;
|
||||||
|
Ok(Json(to_user_response(&user)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn patch_profile(State(s): State<AppState>, AuthUser(uid): AuthUser, Json(body): Json<UpdateProfileRequest>) -> Result<Json<UserResponse>, ApiError> {
|
||||||
|
update_profile(&*s.users, &uid, body.display_name, body.bio, body.avatar_url, body.header_url, body.custom_css).await?;
|
||||||
|
let user = s.users.find_by_id(&uid).await?.ok_or(domain::errors::DomainError::NotFound)?;
|
||||||
|
Ok(Json(to_user_response(&user)))
|
||||||
|
}
|
||||||
41
crates/presentation/src/lib.rs
Normal file
41
crates/presentation/src/lib.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
pub mod errors;
|
||||||
|
pub mod extractors;
|
||||||
|
pub mod handlers;
|
||||||
|
pub mod routes;
|
||||||
|
pub mod state;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use state::AppState;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher};
|
||||||
|
|
||||||
|
struct NoOpEventPublisher;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl EventPublisher for NoOpEventPublisher {
|
||||||
|
async fn publish(&self, _e: &DomainEvent) -> Result<(), DomainError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_state(pool: PgPool, jwt_secret: String) -> AppState {
|
||||||
|
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())),
|
||||||
|
auth: Arc::new(auth::JwtAuthService::new(jwt_secret, 86400 * 30)),
|
||||||
|
hasher: Arc::new(auth::Argon2PasswordHasher),
|
||||||
|
events: Arc::new(NoOpEventPublisher),
|
||||||
|
}
|
||||||
|
}
|
||||||
28
crates/presentation/src/main.rs
Normal file
28
crates/presentation/src/main.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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);
|
||||||
|
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();
|
||||||
|
}
|
||||||
60
crates/presentation/src/routes.rs
Normal file
60
crates/presentation/src/routes.rs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
use axum::{
|
||||||
|
routing::{delete, get, patch, post, put},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use crate::{handlers::*, state::AppState};
|
||||||
|
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
// auth
|
||||||
|
.route("/auth/register", post(auth::post_register))
|
||||||
|
.route("/auth/login", post(auth::post_login))
|
||||||
|
// users — static paths before parameterised
|
||||||
|
.route("/users/me", patch(users::patch_profile))
|
||||||
|
.route("/users/me/top-friends", put(social::put_top_friends))
|
||||||
|
.route("/users/{username}", get(users::get_user))
|
||||||
|
.route("/users/{username}/following", get(feed::get_following_handler))
|
||||||
|
.route("/users/{username}/followers", get(feed::get_followers_handler))
|
||||||
|
.route("/users/{username}/top-friends", get(social::get_top_friends_handler))
|
||||||
|
// follows & blocks (use {id} param)
|
||||||
|
.route(
|
||||||
|
"/users/{id}/follow",
|
||||||
|
post(social::post_follow).delete(social::delete_follow),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/users/{id}/block",
|
||||||
|
post(social::post_block).delete(social::delete_block),
|
||||||
|
)
|
||||||
|
// thoughts
|
||||||
|
.route("/thoughts", post(thoughts::post_thought))
|
||||||
|
.route(
|
||||||
|
"/thoughts/{id}",
|
||||||
|
get(thoughts::get_thought_handler)
|
||||||
|
.patch(thoughts::patch_thought)
|
||||||
|
.delete(thoughts::delete_thought_handler),
|
||||||
|
)
|
||||||
|
.route("/thoughts/{id}/thread", get(thoughts::get_thread_handler))
|
||||||
|
// likes & boosts
|
||||||
|
.route(
|
||||||
|
"/thoughts/{id}/like",
|
||||||
|
post(social::post_like).delete(social::delete_like),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/thoughts/{id}/boost",
|
||||||
|
post(social::post_boost).delete(social::delete_boost),
|
||||||
|
)
|
||||||
|
// feeds
|
||||||
|
.route("/feed", get(feed::home_feed))
|
||||||
|
.route("/feed/public", get(feed::public_feed))
|
||||||
|
.route("/search", get(feed::search_handler))
|
||||||
|
// notifications
|
||||||
|
.route("/notifications", get(notifications::list_notifications))
|
||||||
|
.route("/notifications/read-all", post(notifications::mark_all_read))
|
||||||
|
.route("/notifications/{id}/read", post(notifications::mark_notification_read))
|
||||||
|
// api keys
|
||||||
|
.route(
|
||||||
|
"/api-keys",
|
||||||
|
get(api_keys::get_api_keys).post(api_keys::post_api_key),
|
||||||
|
)
|
||||||
|
.route("/api-keys/{id}", delete(api_keys::delete_api_key_handler))
|
||||||
|
}
|
||||||
21
crates/presentation/src/state.rs
Normal file
21
crates/presentation/src/state.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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 auth: Arc<dyn AuthService>,
|
||||||
|
pub hasher: Arc<dyn PasswordHasher>,
|
||||||
|
pub events: Arc<dyn EventPublisher>,
|
||||||
|
}
|
||||||
4
crates/worker/Cargo.toml
Normal file
4
crates/worker/Cargo.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[package]
|
||||||
|
name = "worker"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
1
crates/worker/src/main.rs
Normal file
1
crates/worker/src/main.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
fn main() {}
|
||||||
3529
docs/superpowers/plans/2026-05-14-v2-plan1-core.md
Normal file
3529
docs/superpowers/plans/2026-05-14-v2-plan1-core.md
Normal file
File diff suppressed because it is too large
Load Diff
285
docs/superpowers/specs/2026-05-14-v2-rewrite-design.md
Normal file
285
docs/superpowers/specs/2026-05-14-v2-rewrite-design.md
Normal 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 (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.
|
||||||
Reference in New Issue
Block a user