feat: v2 rewrite — hexagonal arch, ActivityPub federation, NATS, deployment-ready (#1)
This commit was merged in pull request #1.
This commit is contained in:
406
crates/adapters/postgres/src/activitypub.rs
Normal file
406
crates/adapters/postgres/src/activitypub.rs
Normal file
@@ -0,0 +1,406 @@
|
||||
use crate::db_error::IntoDbResult;
|
||||
use async_trait::async_trait;
|
||||
|
||||
const MAX_REMOTE_CONTENT_CHARS: usize = 500;
|
||||
const THOUGHTS_PATH_PREFIX: &str = "/thoughts/";
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use activitypub_base::{ActivityPubRepository, ActorApUrls, OutboxEntry};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::thought::{Thought, Visibility},
|
||||
value_objects::{Content, ThoughtId, UserId, Username},
|
||||
};
|
||||
|
||||
pub struct PgActivityPubRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgActivityPubRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ActivityPubRepository for PgActivityPubRepository {
|
||||
async fn outbox_entries_for_actor(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<OutboxEntry>, DomainError> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
id: uuid::Uuid,
|
||||
user_id: uuid::Uuid,
|
||||
content: String,
|
||||
created_at: DateTime<Utc>,
|
||||
in_reply_to_id: Option<uuid::Uuid>,
|
||||
content_warning: Option<String>,
|
||||
sensitive: bool,
|
||||
username: String,
|
||||
updated_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
|
||||
FROM thoughts t JOIN users u ON u.id=t.user_id
|
||||
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'
|
||||
ORDER BY t.created_at DESC",
|
||||
)
|
||||
.bind(user_id.as_uuid())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
.map(|rows| {
|
||||
rows.into_iter()
|
||||
.map(|r| OutboxEntry {
|
||||
thought: Thought {
|
||||
id: ThoughtId::from_uuid(r.id),
|
||||
user_id: UserId::from_uuid(r.user_id),
|
||||
content: Content::new_remote(r.content),
|
||||
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
|
||||
visibility: Visibility::Public,
|
||||
content_warning: r.content_warning,
|
||||
sensitive: r.sensitive,
|
||||
local: true,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
},
|
||||
author_username: Username::from_trusted(r.username),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
async fn outbox_page_for_actor(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
before: Option<DateTime<Utc>>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<OutboxEntry>, DomainError> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
id: uuid::Uuid,
|
||||
user_id: uuid::Uuid,
|
||||
content: String,
|
||||
created_at: DateTime<Utc>,
|
||||
in_reply_to_id: Option<uuid::Uuid>,
|
||||
content_warning: Option<String>,
|
||||
sensitive: bool,
|
||||
username: String,
|
||||
updated_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
let rows = if let Some(before) = before {
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
|
||||
FROM thoughts t JOIN users u ON u.id=t.user_id
|
||||
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public' AND t.created_at < $2
|
||||
ORDER BY t.created_at DESC LIMIT $3",
|
||||
)
|
||||
.bind(user_id.as_uuid())
|
||||
.bind(before)
|
||||
.bind(limit as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT t.id, t.user_id, t.content, t.created_at, t.in_reply_to_id, t.content_warning, t.sensitive, u.username, t.updated_at
|
||||
FROM thoughts t JOIN users u ON u.id=t.user_id
|
||||
WHERE t.user_id=$1 AND t.local=true AND t.visibility='public'
|
||||
ORDER BY t.created_at DESC LIMIT $2",
|
||||
)
|
||||
.bind(user_id.as_uuid())
|
||||
.bind(limit as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
.into_domain()?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| OutboxEntry {
|
||||
thought: Thought {
|
||||
id: ThoughtId::from_uuid(r.id),
|
||||
user_id: UserId::from_uuid(r.user_id),
|
||||
content: Content::new_remote(r.content),
|
||||
in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid),
|
||||
visibility: Visibility::Public,
|
||||
content_warning: r.content_warning,
|
||||
sensitive: r.sensitive,
|
||||
local: true,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
},
|
||||
author_username: Username::from_trusted(r.username),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn find_remote_actor_id(
|
||||
&self,
|
||||
actor_ap_url: &str,
|
||||
) -> Result<Option<UserId>, DomainError> {
|
||||
sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM users WHERE ap_id=$1")
|
||||
.bind(actor_ap_url)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
.map(|o| o.map(UserId::from_uuid))
|
||||
}
|
||||
|
||||
async fn intern_remote_actor(&self, actor_ap_url: &str) -> Result<UserId, DomainError> {
|
||||
if let Some(id) = self.find_remote_actor_id(actor_ap_url).await? {
|
||||
return Ok(id);
|
||||
}
|
||||
let new_id = uuid::Uuid::new_v4();
|
||||
// Use the last path segment as username (e.g. /users/alice → "alice").
|
||||
// Falls back to a random short id for long segments (e.g. UUID-based actor URLs).
|
||||
// username column is VARCHAR(32).
|
||||
let last_seg = url::Url::parse(actor_ap_url)
|
||||
.ok()
|
||||
.and_then(|u| {
|
||||
u.path_segments()
|
||||
.and_then(|mut s| s.next_back().map(|s| s.to_string()))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let handle = if last_seg.is_empty() {
|
||||
format!("remote_{}", &new_id.to_string()[..13])
|
||||
} else if last_seg.len() <= 32 {
|
||||
last_seg
|
||||
} else {
|
||||
format!("remote_{}", &new_id.to_string()[..13])
|
||||
};
|
||||
sqlx::query(
|
||||
"INSERT INTO users(id,username,email,password_hash,local,ap_id,created_at,updated_at)
|
||||
VALUES($1,$2,$3,'',false,$4,NOW(),NOW()) ON CONFLICT(ap_id) DO NOTHING",
|
||||
)
|
||||
.bind(new_id)
|
||||
.bind(&handle)
|
||||
.bind(format!("{}@remote", new_id))
|
||||
.bind(actor_ap_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
// Re-fetch to get whichever id won the race
|
||||
self.find_remote_actor_id(actor_ap_url)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
DomainError::Internal(
|
||||
"intern_remote_actor: insert succeeded but row not found".into(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_remote_actor_display(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
display_name: Option<&str>,
|
||||
avatar_url: Option<&str>,
|
||||
) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"UPDATE users SET display_name=$1, avatar_url=$2, updated_at=NOW()
|
||||
WHERE id=$3 AND local=false",
|
||||
)
|
||||
.bind(display_name)
|
||||
.bind(avatar_url)
|
||||
.bind(user_id.as_uuid())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn accept_note(
|
||||
&self,
|
||||
ap_id: &str,
|
||||
author_id: &UserId,
|
||||
content: &str,
|
||||
published: DateTime<Utc>,
|
||||
sensitive: bool,
|
||||
content_warning: Option<String>,
|
||||
visibility: &str,
|
||||
in_reply_to: Option<&str>,
|
||||
) -> Result<ThoughtId, DomainError> {
|
||||
let capped: String = content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect();
|
||||
let (in_reply_to_id, in_reply_to_url) = match in_reply_to {
|
||||
Some(url) => {
|
||||
// If the parent is a local thought, extract its UUID for in_reply_to_id.
|
||||
let local_uuid = url::Url::parse(url).ok().and_then(|u| {
|
||||
u.path()
|
||||
.strip_prefix(THOUGHTS_PATH_PREFIX)
|
||||
.and_then(|s| s.split('/').next())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok())
|
||||
});
|
||||
(local_uuid, Some(url.to_string()))
|
||||
}
|
||||
None => (None, None),
|
||||
};
|
||||
sqlx::query(
|
||||
"INSERT INTO thoughts(id,user_id,content,ap_id,visibility,sensitive,local,content_warning,created_at,in_reply_to_id,in_reply_to_url)
|
||||
VALUES($1,$2,$3,$4,$8,$5,false,$6,$7,$9,$10) ON CONFLICT(ap_id) DO NOTHING",
|
||||
)
|
||||
.bind(uuid::Uuid::new_v4())
|
||||
.bind(author_id.as_uuid())
|
||||
.bind(&capped)
|
||||
.bind(ap_id)
|
||||
.bind(sensitive)
|
||||
.bind(content_warning)
|
||||
.bind(published)
|
||||
.bind(visibility)
|
||||
.bind(in_reply_to_id)
|
||||
.bind(&in_reply_to_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
|
||||
// SELECT the id — works whether the INSERT was a no-op or not (idempotent).
|
||||
let row: (uuid::Uuid,) =
|
||||
sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1")
|
||||
.bind(ap_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
Ok(ThoughtId::from_uuid(row.0))
|
||||
}
|
||||
|
||||
async fn apply_note_update(&self, ap_id: &str, new_content: &str) -> Result<(), DomainError> {
|
||||
let capped: String = new_content.chars().take(MAX_REMOTE_CONTENT_CHARS).collect();
|
||||
sqlx::query(
|
||||
"UPDATE thoughts SET content=$2,updated_at=NOW() WHERE ap_id=$1 AND local=false",
|
||||
)
|
||||
.bind(ap_id)
|
||||
.bind(&capped)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn retract_note(&self, ap_id: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM thoughts WHERE ap_id=$1 AND local=false")
|
||||
.bind(ap_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn retract_actor_notes(&self, actor_ap_url: &str) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"DELETE FROM thoughts WHERE local=false AND user_id=(SELECT id FROM users WHERE ap_id=$1)",
|
||||
)
|
||||
.bind(actor_ap_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn count_local_notes(&self) -> Result<u64, DomainError> {
|
||||
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts WHERE local=true")
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
Ok(n as u64)
|
||||
}
|
||||
|
||||
async fn get_thought_ap_id(
|
||||
&self,
|
||||
thought_id: &ThoughtId,
|
||||
) -> Result<Option<String>, DomainError> {
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT ap_id FROM thoughts WHERE id = $1 AND ap_id IS NOT NULL",
|
||||
)
|
||||
.bind(thought_id.as_uuid())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
}
|
||||
|
||||
async fn get_actor_ap_urls(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Option<ActorApUrls>, DomainError> {
|
||||
sqlx::query_as::<_, (String, String)>(
|
||||
"SELECT ap_id, inbox_url FROM users \
|
||||
WHERE id = $1 AND ap_id IS NOT NULL AND inbox_url IS NOT NULL",
|
||||
)
|
||||
.bind(user_id.as_uuid())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
.map(|opt| opt.map(|(ap_id, inbox_url)| ActorApUrls { ap_id, inbox_url }))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use activitypub_base::ActivityPubRepository;
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) {
|
||||
let repo = PgActivityPubRepository::new(pool);
|
||||
let url = "https://mastodon.social/users/alice";
|
||||
let id1 = repo.intern_remote_actor(url).await.unwrap();
|
||||
let id2 = repo.intern_remote_actor(url).await.unwrap();
|
||||
assert_eq!(id1, id2);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn accept_and_retract_note(pool: sqlx::PgPool) {
|
||||
let repo = PgActivityPubRepository::new(pool);
|
||||
let actor_url = "https://remote.example/users/bob";
|
||||
let ap_id = "https://remote.example/notes/1";
|
||||
let author = repo.intern_remote_actor(actor_url).await.unwrap();
|
||||
repo.accept_note(
|
||||
ap_id,
|
||||
&author,
|
||||
"hello from remote",
|
||||
chrono::Utc::now(),
|
||||
false,
|
||||
None,
|
||||
"public",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.retract_note(ap_id).await.unwrap();
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn count_local_notes_excludes_remote(pool: sqlx::PgPool) {
|
||||
let repo = PgActivityPubRepository::new(pool);
|
||||
assert_eq!(repo.count_local_notes().await.unwrap(), 0);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn accept_note_returns_thought_id(pool: sqlx::PgPool) {
|
||||
let repo = PgActivityPubRepository::new(pool.clone());
|
||||
let actor_user_id = repo
|
||||
.intern_remote_actor("https://remote.example/users/alice")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let thought_id = repo
|
||||
.accept_note(
|
||||
"https://remote.example/notes/1",
|
||||
&actor_user_id,
|
||||
"Hello #rust world",
|
||||
chrono::Utc::now(),
|
||||
false,
|
||||
None,
|
||||
"public",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let row: (uuid::Uuid,) = sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1")
|
||||
.bind("https://remote.example/notes/1")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(thought_id.as_uuid(), row.0);
|
||||
}
|
||||
}
|
||||
142
crates/adapters/postgres/src/api_key.rs
Normal file
142
crates/adapters/postgres/src/api_key.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
use crate::db_error::IntoDbResult;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::api_key::ApiKey,
|
||||
ports::ApiKeyRepository,
|
||||
value_objects::{ApiKeyId, UserId},
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
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
|
||||
.into_domain()
|
||||
.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
|
||||
.into_domain()
|
||||
.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
|
||||
.into_domain()
|
||||
.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
|
||||
.into_domain()
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::user::PgUserRepository;
|
||||
use chrono::Utc;
|
||||
use domain::ports::UserWriter;
|
||||
use domain::{models::user::User, value_objects::*};
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
90
crates/adapters/postgres/src/block.rs
Normal file
90
crates/adapters/postgres/src/block.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
use crate::db_error::IntoDbResult;
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError, models::social::Block, ports::BlockRepository, value_objects::UserId,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
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
|
||||
.into_domain()
|
||||
.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
|
||||
.into_domain()
|
||||
.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
|
||||
.into_domain()?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_helpers::seed_user;
|
||||
use chrono::Utc;
|
||||
use domain::value_objects::*;
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
110
crates/adapters/postgres/src/boost.rs
Normal file
110
crates/adapters/postgres/src/boost.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use crate::db_error::IntoDbResult;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::social::Boost,
|
||||
ports::BoostRepository,
|
||||
value_objects::{BoostId, ThoughtId, UserId},
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
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.into_domain().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
|
||||
.into_domain()?;
|
||||
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
|
||||
.into_domain()
|
||||
.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
|
||||
.into_domain()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_helpers::seed_user_and_thought;
|
||||
use chrono::Utc;
|
||||
use domain::value_objects::*;
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn boost_and_count(pool: sqlx::PgPool) {
|
||||
let (user, thought) = seed_user_and_thought(&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_user_and_thought(&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);
|
||||
}
|
||||
}
|
||||
20
crates/adapters/postgres/src/db_error.rs
Normal file
20
crates/adapters/postgres/src/db_error.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use domain::errors::DomainError;
|
||||
|
||||
pub(crate) trait IntoDbResult<T> {
|
||||
fn into_domain(self) -> Result<T, DomainError>;
|
||||
}
|
||||
|
||||
impl<T> IntoDbResult<T> for Result<T, sqlx::Error> {
|
||||
fn into_domain(self) -> Result<T, DomainError> {
|
||||
self.map_err(|e| {
|
||||
if let sqlx::Error::Database(ref db) = e {
|
||||
if db.code().as_deref() == Some("23505") {
|
||||
return DomainError::Conflict(
|
||||
db.constraint().unwrap_or("conflict").to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
DomainError::Internal(e.to_string())
|
||||
})
|
||||
}
|
||||
}
|
||||
83
crates/adapters/postgres/src/engagement.rs
Normal file
83
crates/adapters/postgres/src/engagement.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use crate::db_error::IntoDbResult;
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::feed::{EngagementStats, ViewerContext},
|
||||
ports::EngagementRepository,
|
||||
value_objects::{ThoughtId, UserId},
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub struct PgEngagementRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgEngagementRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EngagementRepository for PgEngagementRepository {
|
||||
async fn get_for_thoughts(
|
||||
&self,
|
||||
thought_ids: &[ThoughtId],
|
||||
viewer_id: Option<&UserId>,
|
||||
) -> Result<HashMap<ThoughtId, (EngagementStats, Option<ViewerContext>)>, DomainError> {
|
||||
if thought_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
thought_id: uuid::Uuid,
|
||||
like_count: i64,
|
||||
boost_count: i64,
|
||||
reply_count: i64,
|
||||
liked_by_viewer: bool,
|
||||
boosted_by_viewer: bool,
|
||||
}
|
||||
|
||||
let ids: Vec<uuid::Uuid> = thought_ids.iter().map(|t| t.as_uuid()).collect();
|
||||
let viewer_uuid: Option<uuid::Uuid> = viewer_id.map(|v| v.as_uuid());
|
||||
|
||||
let rows = sqlx::query_as::<_, Row>(
|
||||
"SELECT
|
||||
t.id AS thought_id,
|
||||
COUNT(DISTINCT l.user_id) AS like_count,
|
||||
COUNT(DISTINCT b.user_id) AS boost_count,
|
||||
COUNT(DISTINCT r.id) AS reply_count,
|
||||
COALESCE(BOOL_OR(l.user_id = $2), false) AS liked_by_viewer,
|
||||
COALESCE(BOOL_OR(b.user_id = $2), false) AS boosted_by_viewer
|
||||
FROM thoughts t
|
||||
LEFT JOIN likes l ON l.thought_id = t.id
|
||||
LEFT JOIN boosts b ON b.thought_id = t.id
|
||||
LEFT JOIN thoughts r ON r.in_reply_to_id = t.id
|
||||
WHERE t.id = ANY($1)
|
||||
GROUP BY t.id",
|
||||
)
|
||||
.bind(&ids[..])
|
||||
.bind(viewer_uuid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
|
||||
let mut result = HashMap::new();
|
||||
for row in rows {
|
||||
let tid = ThoughtId::from_uuid(row.thought_id);
|
||||
let stats = EngagementStats {
|
||||
like_count: row.like_count,
|
||||
boost_count: row.boost_count,
|
||||
reply_count: row.reply_count,
|
||||
};
|
||||
let viewer = viewer_id.map(|_| ViewerContext {
|
||||
liked: row.liked_by_viewer,
|
||||
boosted: row.boosted_by_viewer,
|
||||
});
|
||||
result.insert(tid, (stats, viewer));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
105
crates/adapters/postgres/src/failed_event.rs
Normal file
105
crates/adapters/postgres/src/failed_event.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
|
||||
/// How many times a failed event is retried by the DLQ processor.
|
||||
pub const DLQ_MAX_RETRIES: i32 = 3;
|
||||
/// Quarantine period for the first DLQ retry (seconds). Doubles each retry.
|
||||
pub const DLQ_INITIAL_BACKOFF_SECS: i64 = 300; // 5 minutes
|
||||
/// How often the DLQ processor polls for due retries (seconds).
|
||||
pub const DLQ_POLL_INTERVAL_SECS: u64 = 60;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub struct FailedEvent {
|
||||
pub id: uuid::Uuid,
|
||||
pub event_type: String,
|
||||
pub payload: serde_json::Value,
|
||||
pub failed_at: DateTime<Utc>,
|
||||
pub retry_at: DateTime<Utc>,
|
||||
pub retry_count: i32,
|
||||
pub last_error: String,
|
||||
}
|
||||
|
||||
pub struct PgFailedEventStore {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgFailedEventStore {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
/// Insert a newly exhausted event into the DLQ.
|
||||
pub async fn insert(
|
||||
&self,
|
||||
event_type: &str,
|
||||
payload: &serde_json::Value,
|
||||
last_error: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let retry_at = Utc::now() + chrono::Duration::seconds(DLQ_INITIAL_BACKOFF_SECS);
|
||||
sqlx::query(
|
||||
"INSERT INTO failed_events \
|
||||
(event_type, payload, retry_at, last_error) \
|
||||
VALUES ($1, $2, $3, $4)",
|
||||
)
|
||||
.bind(event_type)
|
||||
.bind(payload)
|
||||
.bind(retry_at)
|
||||
.bind(last_error)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch all events due for retry (retry_at <= now, retry_count < DLQ_MAX_RETRIES).
|
||||
pub async fn poll_due(&self) -> Result<Vec<FailedEvent>, sqlx::Error> {
|
||||
sqlx::query_as::<_, FailedEvent>(
|
||||
"SELECT id, event_type, payload, failed_at, retry_at, retry_count, last_error \
|
||||
FROM failed_events \
|
||||
WHERE retry_at <= now() AND retry_count < $1 \
|
||||
ORDER BY retry_at \
|
||||
LIMIT 100",
|
||||
)
|
||||
.bind(DLQ_MAX_RETRIES)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Advance a row after a republish attempt using exponential backoff.
|
||||
/// next_retry = now + initial * 2^retry_count
|
||||
pub async fn advance(&self, id: uuid::Uuid, error: Option<&str>) -> Result<(), sqlx::Error> {
|
||||
let current: i32 =
|
||||
sqlx::query_scalar("SELECT retry_count FROM failed_events WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
|
||||
let new_count = current + 1;
|
||||
let backoff_secs = DLQ_INITIAL_BACKOFF_SECS * (1_i64 << new_count.min(10));
|
||||
let retry_at = Utc::now() + chrono::Duration::seconds(backoff_secs);
|
||||
let last_error = error.unwrap_or("republish succeeded");
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE failed_events \
|
||||
SET retry_count = $1, retry_at = $2, last_error = $3 \
|
||||
WHERE id = $4",
|
||||
)
|
||||
.bind(new_count)
|
||||
.bind(retry_at)
|
||||
.bind(last_error)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Park a permanently failed event (retry_count >= DLQ_MAX_RETRIES).
|
||||
pub async fn park_permanently(&self, id: uuid::Uuid) -> Result<(), sqlx::Error> {
|
||||
let far_future = Utc::now() + chrono::Duration::days(365);
|
||||
sqlx::query("UPDATE failed_events SET retry_at = $1 WHERE id = $2")
|
||||
.bind(far_future)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
399
crates/adapters/postgres/src/feed.rs
Normal file
399
crates/adapters/postgres/src/feed.rs
Normal file
@@ -0,0 +1,399 @@
|
||||
use crate::db_error::IntoDbResult;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
feed::{FeedEntry, Paginated},
|
||||
thought::{Thought, Visibility},
|
||||
user::User,
|
||||
},
|
||||
ports::{FeedQuery, FeedRepository, FeedScope},
|
||||
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
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>,
|
||||
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,
|
||||
author_created_at: DateTime<Utc>,
|
||||
author_updated_at: DateTime<Utc>,
|
||||
like_count: i64,
|
||||
boost_count: i64,
|
||||
reply_count: i64,
|
||||
liked_by_viewer: bool,
|
||||
boosted_by_viewer: bool,
|
||||
}
|
||||
|
||||
fn federation_following_clause(follower: Option<uuid::Uuid>) -> String {
|
||||
match follower {
|
||||
Some(fid) => format!(
|
||||
" OR t.user_id IN (
|
||||
SELECT u2.id FROM users u2
|
||||
JOIN federation_following ff ON u2.ap_id = ff.remote_actor_url
|
||||
WHERE ff.local_user_id = '{fid}'
|
||||
)"
|
||||
),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn feed_select(viewer: Option<uuid::Uuid>) -> String {
|
||||
let viewer_checks = match viewer {
|
||||
Some(uid) => format!(
|
||||
"EXISTS(SELECT 1 FROM likes WHERE user_id='{uid}' AND thought_id=t.id) AS liked_by_viewer,
|
||||
EXISTS(SELECT 1 FROM boosts WHERE user_id='{uid}' AND thought_id=t.id) AS boosted_by_viewer"
|
||||
),
|
||||
None => "false AS liked_by_viewer, false AS boosted_by_viewer".to_string(),
|
||||
};
|
||||
format!(
|
||||
"
|
||||
SELECT
|
||||
t.id AS thought_id, t.user_id AS t_user_id, t.content,
|
||||
t.in_reply_to_id,
|
||||
t.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,
|
||||
CASE WHEN NOT u.local AND ra.handle IS NOT NULL AND ra.handle != ''
|
||||
THEN '@' || ra.handle ||
|
||||
CASE WHEN ra.handle NOT LIKE '%@%'
|
||||
THEN '@' || SPLIT_PART(ra.url, '/', 3)
|
||||
ELSE '' END
|
||||
ELSE u.username END AS username,
|
||||
u.email, u.password_hash,
|
||||
COALESCE(ra.display_name, u.display_name) AS display_name,
|
||||
u.bio,
|
||||
COALESCE(ra.avatar_url, u.avatar_url) AS avatar_url,
|
||||
u.header_url, u.custom_css,
|
||||
u.local AS author_local,
|
||||
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,
|
||||
{viewer_checks}
|
||||
FROM thoughts t
|
||||
JOIN users u ON u.id=t.user_id
|
||||
LEFT JOIN remote_actors ra ON u.ap_id = ra.url"
|
||||
)
|
||||
}
|
||||
|
||||
fn row_to_entry(r: FeedRow, viewer: Option<uuid::Uuid>) -> Result<FeedEntry, DomainError> {
|
||||
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),
|
||||
visibility: Visibility::from_db_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,
|
||||
created_at: r.author_created_at,
|
||||
updated_at: r.author_updated_at,
|
||||
};
|
||||
Ok(FeedEntry {
|
||||
thought,
|
||||
author,
|
||||
stats: domain::models::feed::EngagementStats {
|
||||
like_count: r.like_count,
|
||||
boost_count: r.boost_count,
|
||||
reply_count: r.reply_count,
|
||||
},
|
||||
viewer: viewer.map(|_| domain::models::feed::ViewerContext {
|
||||
liked: r.liked_by_viewer,
|
||||
boosted: r.boosted_by_viewer,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FeedRepository for PgFeedRepository {
|
||||
async fn query(&self, q: &FeedQuery) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
let viewer = q.viewer_id.as_ref().map(|v| v.as_uuid());
|
||||
let page = &q.page;
|
||||
|
||||
match &q.scope {
|
||||
FeedScope::Home { following_ids } => {
|
||||
let ids: Vec<uuid::Uuid> = following_ids.iter().map(|id| id.as_uuid()).collect();
|
||||
let fed_clause = federation_following_clause(viewer);
|
||||
let count_sql = format!(
|
||||
"SELECT COUNT(*) FROM thoughts t WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct'",
|
||||
fed_clause
|
||||
);
|
||||
let total: i64 = sqlx::query_scalar(&count_sql)
|
||||
.bind(&ids)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
|
||||
let sel = feed_select(viewer);
|
||||
let sql = format!("{sel} WHERE (t.user_id=ANY($1){}) AND t.visibility != 'direct' ORDER BY t.created_at DESC LIMIT $2 OFFSET $3", fed_clause);
|
||||
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||
.bind(&ids)
|
||||
.bind(page.limit())
|
||||
.bind(page.offset())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
|
||||
Ok(Paginated {
|
||||
items: rows
|
||||
.into_iter()
|
||||
.map(|r| row_to_entry(r, viewer))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
total,
|
||||
page: page.page,
|
||||
per_page: page.per_page,
|
||||
})
|
||||
}
|
||||
|
||||
FeedScope::Public => {
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'",
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
|
||||
let sel = feed_select(viewer);
|
||||
let sql = format!("{sel} WHERE t.local=true AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $1 OFFSET $2");
|
||||
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||
.bind(page.limit())
|
||||
.bind(page.offset())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
|
||||
Ok(Paginated {
|
||||
items: rows
|
||||
.into_iter()
|
||||
.map(|r| row_to_entry(r, viewer))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
total,
|
||||
page: page.page,
|
||||
per_page: page.per_page,
|
||||
})
|
||||
}
|
||||
|
||||
FeedScope::Search { query } => {
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'",
|
||||
)
|
||||
.bind(query)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
|
||||
let sel = feed_select(viewer);
|
||||
let sql = format!("{sel} WHERE t.content % $1 AND t.visibility='public' ORDER BY similarity(t.content, $1) DESC LIMIT $2 OFFSET $3");
|
||||
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||
.bind(query)
|
||||
.bind(page.limit())
|
||||
.bind(page.offset())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
|
||||
Ok(Paginated {
|
||||
items: rows
|
||||
.into_iter()
|
||||
.map(|r| row_to_entry(r, viewer))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
total,
|
||||
page: page.page,
|
||||
per_page: page.per_page,
|
||||
})
|
||||
}
|
||||
|
||||
FeedScope::Tag { tag_name } => {
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM thoughts t
|
||||
JOIN thought_tags tt ON tt.thought_id = t.id
|
||||
JOIN tags tg ON tg.id = tt.tag_id
|
||||
WHERE tg.name = $1 AND t.visibility = 'public'",
|
||||
)
|
||||
.bind(tag_name)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
|
||||
let sel = feed_select(viewer);
|
||||
let sql = format!(
|
||||
"{sel}
|
||||
JOIN thought_tags tt ON tt.thought_id = t.id
|
||||
JOIN tags tg ON tg.id = tt.tag_id
|
||||
WHERE tg.name = $1 AND t.visibility = 'public'
|
||||
ORDER BY t.created_at DESC LIMIT $2 OFFSET $3"
|
||||
);
|
||||
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||
.bind(tag_name)
|
||||
.bind(page.limit())
|
||||
.bind(page.offset())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
|
||||
Ok(Paginated {
|
||||
items: rows
|
||||
.into_iter()
|
||||
.map(|r| row_to_entry(r, viewer))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
total,
|
||||
page: page.page,
|
||||
per_page: page.per_page,
|
||||
})
|
||||
}
|
||||
|
||||
FeedScope::User { user_id } => {
|
||||
let uid = user_id.as_uuid();
|
||||
// Use nil UUID for unauthenticated viewers — won't match owner or follower checks.
|
||||
let viewer_uuid = viewer.unwrap_or(uuid::Uuid::nil());
|
||||
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id = $1 AND ($2::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $2 AND following_id = $1 AND state = 'accepted')))))",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(viewer_uuid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
|
||||
let sel = feed_select(viewer);
|
||||
let sql = format!("{sel} WHERE t.user_id = $1 AND ($4::uuid = $1 OR (t.visibility != 'direct' AND (t.visibility IN ('public', 'unlisted') OR (t.visibility = 'followers' AND EXISTS(SELECT 1 FROM follows WHERE follower_id = $4 AND following_id = $1 AND state = 'accepted'))))) ORDER BY t.created_at DESC LIMIT $2 OFFSET $3");
|
||||
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||
.bind(uid)
|
||||
.bind(page.limit())
|
||||
.bind(page.offset())
|
||||
.bind(viewer_uuid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
|
||||
Ok(Paginated {
|
||||
items: rows
|
||||
.into_iter()
|
||||
.map(|r| row_to_entry(r, viewer))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
total,
|
||||
page: page.page,
|
||||
per_page: page.per_page,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
||||
use domain::{
|
||||
models::{
|
||||
feed::PageParams,
|
||||
thought::{Thought, Visibility},
|
||||
user::User,
|
||||
},
|
||||
ports::{FeedQuery, ThoughtRepository, UserWriter},
|
||||
value_objects::*,
|
||||
};
|
||||
|
||||
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
|
||||
.query(&FeedQuery::public(
|
||||
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
|
||||
.query(&FeedQuery::search(
|
||||
"hello world",
|
||||
PageParams { page: 1, per_page: 20 },
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.total >= 1);
|
||||
assert!(result
|
||||
.items
|
||||
.iter()
|
||||
.any(|e| e.thought.content.as_str() == "hello world"));
|
||||
}
|
||||
}
|
||||
252
crates/adapters/postgres/src/follow.rs
Normal file
252
crates/adapters/postgres/src/follow.rs
Normal file
@@ -0,0 +1,252 @@
|
||||
use crate::db_error::IntoDbResult;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
feed::{PageParams, Paginated},
|
||||
social::{Follow, FollowState},
|
||||
user::User,
|
||||
},
|
||||
ports::FollowRepository,
|
||||
value_objects::UserId,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
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
|
||||
.into_domain()
|
||||
.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
|
||||
.into_domain()?;
|
||||
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
|
||||
.into_domain()
|
||||
.and_then(|o| {
|
||||
o.map(|r| {
|
||||
Ok(Follow {
|
||||
follower_id: UserId::from_uuid(r.follower_id),
|
||||
following_id: UserId::from_uuid(r.following_id),
|
||||
state: FollowState::from_db_str(&r.state)?,
|
||||
ap_id: r.ap_id,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
.into_domain()
|
||||
.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
|
||||
.into_domain()?;
|
||||
|
||||
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.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
|
||||
.into_domain()?;
|
||||
|
||||
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
|
||||
.into_domain()?;
|
||||
|
||||
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.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
|
||||
.into_domain()?;
|
||||
|
||||
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
|
||||
.into_domain()?;
|
||||
Ok(ids.into_iter().map(UserId::from_uuid).collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_helpers::seed_user;
|
||||
use chrono::Utc;
|
||||
use domain::value_objects::*;
|
||||
|
||||
#[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]);
|
||||
}
|
||||
}
|
||||
20
crates/adapters/postgres/src/lib.rs
Normal file
20
crates/adapters/postgres/src/lib.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
pub mod activitypub;
|
||||
pub mod engagement;
|
||||
pub mod api_key;
|
||||
pub mod block;
|
||||
pub mod boost;
|
||||
mod db_error;
|
||||
pub mod failed_event;
|
||||
pub mod outbox;
|
||||
pub mod feed;
|
||||
pub mod follow;
|
||||
pub mod like;
|
||||
pub mod notification;
|
||||
pub mod remote_actor;
|
||||
pub mod remote_actor_connections;
|
||||
pub mod tag;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_helpers;
|
||||
pub mod thought;
|
||||
pub mod top_friend;
|
||||
pub mod user;
|
||||
110
crates/adapters/postgres/src/like.rs
Normal file
110
crates/adapters/postgres/src/like.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use crate::db_error::IntoDbResult;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::social::Like,
|
||||
ports::LikeRepository,
|
||||
value_objects::{LikeId, ThoughtId, UserId},
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
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.into_domain().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
|
||||
.into_domain()?;
|
||||
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
|
||||
.into_domain()
|
||||
.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
|
||||
.into_domain()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_helpers::seed_user_and_thought;
|
||||
use chrono::Utc;
|
||||
use domain::value_objects::*;
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn like_and_count(pool: sqlx::PgPool) {
|
||||
let (user, thought) = seed_user_and_thought(&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_user_and_thought(&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);
|
||||
}
|
||||
}
|
||||
230
crates/adapters/postgres/src/notification.rs
Normal file
230
crates/adapters/postgres/src/notification.rs
Normal file
@@ -0,0 +1,230 @@
|
||||
use crate::db_error::IntoDbResult;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
feed::{PageParams, Paginated},
|
||||
notification::{Notification, NotificationKind},
|
||||
},
|
||||
ports::NotificationRepository,
|
||||
value_objects::{NotificationId, ThoughtId, UserId},
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub struct PgNotificationRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
impl PgNotificationRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct NotificationRow {
|
||||
id: uuid::Uuid,
|
||||
user_id: uuid::Uuid,
|
||||
notification_type: String,
|
||||
from_user_id: Option<uuid::Uuid>,
|
||||
thought_id: Option<uuid::Uuid>,
|
||||
read: bool,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
fn row_to_notification(r: NotificationRow) -> Result<Notification, DomainError> {
|
||||
let from_user_id = r
|
||||
.from_user_id
|
||||
.map(UserId::from_uuid)
|
||||
.ok_or_else(|| DomainError::Internal("notification missing from_user_id".into()))?;
|
||||
|
||||
let kind = match r.notification_type.as_str() {
|
||||
"follow" => NotificationKind::Follow { from_user_id },
|
||||
other => {
|
||||
let thought_id = r.thought_id.map(ThoughtId::from_uuid).ok_or_else(|| {
|
||||
DomainError::Internal(format!("notification type '{other}' missing thought_id"))
|
||||
})?;
|
||||
match other {
|
||||
"like" => NotificationKind::Like {
|
||||
thought_id,
|
||||
from_user_id,
|
||||
},
|
||||
"boost" => NotificationKind::Boost {
|
||||
thought_id,
|
||||
from_user_id,
|
||||
},
|
||||
"reply" => NotificationKind::Reply {
|
||||
thought_id,
|
||||
from_user_id,
|
||||
},
|
||||
"mention" => NotificationKind::Mention {
|
||||
thought_id,
|
||||
from_user_id,
|
||||
},
|
||||
_ => {
|
||||
return Err(DomainError::Internal(format!(
|
||||
"unknown notification type: {other}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Notification {
|
||||
id: NotificationId::from_uuid(r.id),
|
||||
user_id: UserId::from_uuid(r.user_id),
|
||||
kind,
|
||||
read: r.read,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NotificationRepository for PgNotificationRepository {
|
||||
async fn save(&self, n: &Notification) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO notifications(id,user_id,notification_type,from_user_id,thought_id,read,created_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7)
|
||||
ON CONFLICT(id) DO NOTHING"
|
||||
)
|
||||
.bind(n.id.as_uuid())
|
||||
.bind(n.user_id.as_uuid())
|
||||
.bind(n.kind.kind_str())
|
||||
.bind(n.kind.from_user_id().as_uuid())
|
||||
.bind(n.kind.thought_id().map(|t| t.as_uuid()))
|
||||
.bind(n.read)
|
||||
.bind(n.created_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
.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
|
||||
.into_domain()?;
|
||||
let rows = sqlx::query_as::<_, NotificationRow>(
|
||||
"SELECT id,user_id,notification_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.into_domain()?;
|
||||
let items = rows
|
||||
.into_iter()
|
||||
.map(row_to_notification)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(Paginated {
|
||||
items,
|
||||
total,
|
||||
page: page.page,
|
||||
per_page: page.per_page,
|
||||
})
|
||||
}
|
||||
|
||||
async fn count_unread(&self, user_id: &UserId) -> Result<u64, DomainError> {
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM notifications WHERE user_id=$1 AND read=false",
|
||||
)
|
||||
.bind(user_id.as_uuid())
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
Ok(count as u64)
|
||||
}
|
||||
|
||||
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
|
||||
.into_domain()
|
||||
.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
|
||||
.into_domain()
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_helpers;
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
models::{notification::NotificationKind, user::User},
|
||||
value_objects::*,
|
||||
};
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn save_and_list(pool: sqlx::PgPool) {
|
||||
let user = test_helpers::seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let from_user = test_helpers::seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgNotificationRepository::new(pool);
|
||||
use domain::models::feed::PageParams;
|
||||
let n = Notification {
|
||||
id: NotificationId::new(),
|
||||
user_id: user.id.clone(),
|
||||
kind: NotificationKind::Follow {
|
||||
from_user_id: from_user.id.clone(),
|
||||
},
|
||||
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 = test_helpers::seed_user(&pool, "alice", "alice@ex.com").await;
|
||||
let from_user = test_helpers::seed_user(&pool, "bob", "bob@ex.com").await;
|
||||
let repo = PgNotificationRepository::new(pool);
|
||||
use domain::models::feed::PageParams;
|
||||
let n = Notification {
|
||||
id: NotificationId::new(),
|
||||
user_id: user.id.clone(),
|
||||
kind: NotificationKind::Follow {
|
||||
from_user_id: from_user.id.clone(),
|
||||
},
|
||||
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);
|
||||
}
|
||||
}
|
||||
61
crates/adapters/postgres/src/outbox.rs
Normal file
61
crates/adapters/postgres/src/outbox.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, events::DomainEvent, ports::OutboxWriter};
|
||||
use event_payload::EventPayload;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct PgOutboxWriter {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgOutboxWriter {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
/// Primary aggregate UUID for an event — used to populate `aggregate_id`.
|
||||
fn aggregate_id(event: &DomainEvent) -> Uuid {
|
||||
match event {
|
||||
DomainEvent::ThoughtCreated { thought_id, .. } => thought_id.as_uuid(),
|
||||
DomainEvent::ThoughtDeleted { thought_id, .. } => thought_id.as_uuid(),
|
||||
DomainEvent::ThoughtUpdated { thought_id, .. } => thought_id.as_uuid(),
|
||||
DomainEvent::LikeAdded { thought_id, .. } => thought_id.as_uuid(),
|
||||
DomainEvent::LikeRemoved { thought_id, .. } => thought_id.as_uuid(),
|
||||
DomainEvent::BoostAdded { thought_id, .. } => thought_id.as_uuid(),
|
||||
DomainEvent::BoostRemoved { thought_id, .. } => thought_id.as_uuid(),
|
||||
DomainEvent::FollowRequested { follower_id, .. } => follower_id.as_uuid(),
|
||||
DomainEvent::FollowAccepted { follower_id, .. } => follower_id.as_uuid(),
|
||||
DomainEvent::FollowRejected { follower_id, .. } => follower_id.as_uuid(),
|
||||
DomainEvent::Unfollowed { follower_id, .. } => follower_id.as_uuid(),
|
||||
DomainEvent::UserBlocked { blocker_id, .. } => blocker_id.as_uuid(),
|
||||
DomainEvent::UserUnblocked { blocker_id, .. } => blocker_id.as_uuid(),
|
||||
DomainEvent::UserRegistered { user_id } => user_id.as_uuid(),
|
||||
DomainEvent::ProfileUpdated { user_id } => user_id.as_uuid(),
|
||||
DomainEvent::MentionReceived { thought_id, .. } => thought_id.as_uuid(),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OutboxWriter for PgOutboxWriter {
|
||||
async fn append(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
let payload = EventPayload::from(event);
|
||||
let event_type = payload.subject();
|
||||
let payload_json =
|
||||
serde_json::to_value(&payload).map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
let agg_id = aggregate_id(event);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO outbox_events (aggregate_id, event_type, payload) \
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(agg_id)
|
||||
.bind(event_type)
|
||||
.bind(payload_json)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
59
crates/adapters/postgres/src/remote_actor.rs
Normal file
59
crates/adapters/postgres/src/remote_actor.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use crate::db_error::IntoDbResult;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use domain::{
|
||||
errors::DomainError, models::remote_actor::RemoteActor, ports::RemoteActorRepository,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
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,avatar_url,last_fetched_at)
|
||||
VALUES($1,$2,$3,$4,$5)
|
||||
ON CONFLICT(url) DO UPDATE SET handle=EXCLUDED.handle,display_name=EXCLUDED.display_name,
|
||||
avatar_url=EXCLUDED.avatar_url,last_fetched_at=EXCLUDED.last_fetched_at"
|
||||
)
|
||||
.bind(&a.url).bind(&a.handle).bind(&a.display_name).bind(&a.avatar_url).bind(a.last_fetched_at)
|
||||
.execute(&self.pool).await.into_domain().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>,
|
||||
avatar_url: Option<String>,
|
||||
last_fetched_at: DateTime<Utc>,
|
||||
}
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT url,handle,display_name,avatar_url,last_fetched_at FROM remote_actors WHERE url=$1"
|
||||
).bind(url).fetch_optional(&self.pool).await
|
||||
.into_domain()
|
||||
.map(|o| o.map(|r| RemoteActor {
|
||||
url: r.url,
|
||||
handle: r.handle,
|
||||
display_name: r.display_name,
|
||||
avatar_url: r.avatar_url,
|
||||
last_fetched_at: r.last_fetched_at,
|
||||
bio: None,
|
||||
banner_url: None,
|
||||
also_known_as: None,
|
||||
outbox_url: None,
|
||||
followers_url: None,
|
||||
following_url: None,
|
||||
attachment: vec![],
|
||||
}))
|
||||
}
|
||||
}
|
||||
111
crates/adapters/postgres/src/remote_actor_connections.rs
Normal file
111
crates/adapters/postgres/src/remote_actor_connections.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
use crate::db_error::IntoDbResult;
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError, models::actor_connection_summary::ActorConnectionSummary,
|
||||
ports::RemoteActorConnectionRepository,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub struct PgRemoteActorConnectionRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PgRemoteActorConnectionRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RemoteActorConnectionRepository for PgRemoteActorConnectionRepository {
|
||||
async fn upsert_connections(
|
||||
&self,
|
||||
actor_url: &str,
|
||||
connection_type: &str,
|
||||
page: u32,
|
||||
actors: &[ActorConnectionSummary],
|
||||
) -> Result<(), DomainError> {
|
||||
for actor in actors {
|
||||
sqlx::query(
|
||||
"INSERT INTO remote_actor_connections
|
||||
(actor_url, connection_type, page, connected_actor_url,
|
||||
connected_handle, connected_display_name, connected_avatar_url, fetched_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||
ON CONFLICT(actor_url, connection_type, page, connected_actor_url)
|
||||
DO UPDATE SET
|
||||
connected_handle = EXCLUDED.connected_handle,
|
||||
connected_display_name = EXCLUDED.connected_display_name,
|
||||
connected_avatar_url = EXCLUDED.connected_avatar_url,
|
||||
fetched_at = NOW()",
|
||||
)
|
||||
.bind(actor_url)
|
||||
.bind(connection_type)
|
||||
.bind(page as i32)
|
||||
.bind(&actor.url)
|
||||
.bind(&actor.handle)
|
||||
.bind(&actor.display_name)
|
||||
.bind(&actor.avatar_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_connections(
|
||||
&self,
|
||||
actor_url: &str,
|
||||
connection_type: &str,
|
||||
page: u32,
|
||||
) -> Result<Vec<ActorConnectionSummary>, DomainError> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
connected_actor_url: String,
|
||||
connected_handle: String,
|
||||
connected_display_name: Option<String>,
|
||||
connected_avatar_url: Option<String>,
|
||||
}
|
||||
let rows = sqlx::query_as::<_, Row>(
|
||||
"SELECT connected_actor_url, connected_handle, connected_display_name, connected_avatar_url
|
||||
FROM remote_actor_connections
|
||||
WHERE actor_url = $1 AND connection_type = $2 AND page = $3
|
||||
ORDER BY connected_handle",
|
||||
)
|
||||
.bind(actor_url)
|
||||
.bind(connection_type)
|
||||
.bind(page as i32)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| ActorConnectionSummary {
|
||||
url: r.connected_actor_url,
|
||||
handle: r.connected_handle,
|
||||
display_name: r.connected_display_name,
|
||||
avatar_url: r.connected_avatar_url,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn connection_page_age(
|
||||
&self,
|
||||
actor_url: &str,
|
||||
connection_type: &str,
|
||||
page: u32,
|
||||
) -> Result<Option<chrono::DateTime<chrono::Utc>>, DomainError> {
|
||||
let row: Option<(Option<chrono::DateTime<chrono::Utc>>,)> = sqlx::query_as(
|
||||
"SELECT MAX(fetched_at) FROM remote_actor_connections
|
||||
WHERE actor_url = $1 AND connection_type = $2 AND page = $3",
|
||||
)
|
||||
.bind(actor_url)
|
||||
.bind(connection_type)
|
||||
.bind(page as i32)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
|
||||
Ok(row.and_then(|(ts,)| ts))
|
||||
}
|
||||
}
|
||||
184
crates/adapters/postgres/src/tag.rs
Normal file
184
crates/adapters/postgres/src/tag.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
use crate::db_error::IntoDbResult;
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
feed::{PageParams, Paginated},
|
||||
tag::Tag,
|
||||
thought::Thought,
|
||||
},
|
||||
ports::TagRepository,
|
||||
value_objects::ThoughtId,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
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
|
||||
.into_domain()?;
|
||||
#[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
|
||||
.into_domain()?;
|
||||
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
|
||||
.into_domain()
|
||||
.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
|
||||
.into_domain()
|
||||
.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
|
||||
.into_domain()
|
||||
.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
|
||||
.into_domain()?;
|
||||
|
||||
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.into_domain()?;
|
||||
|
||||
Ok(Paginated {
|
||||
items: rows
|
||||
.into_iter()
|
||||
.map(Thought::try_from)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
total,
|
||||
page: page.page,
|
||||
per_page: page.per_page,
|
||||
})
|
||||
}
|
||||
|
||||
async fn popular_tags(&self, limit: usize) -> Result<Vec<(String, i64)>, DomainError> {
|
||||
sqlx::query_as::<_, (String, i64)>(
|
||||
"SELECT t.name, COUNT(tt.thought_id) AS thought_count
|
||||
FROM tags t
|
||||
JOIN thought_tags tt ON t.id = tt.tag_id
|
||||
GROUP BY t.id, t.name
|
||||
ORDER BY thought_count DESC
|
||||
LIMIT $1",
|
||||
)
|
||||
.bind(limit as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
||||
use domain::ports::{ThoughtRepository, UserWriter};
|
||||
use domain::{
|
||||
models::{
|
||||
thought::{Thought, Visibility},
|
||||
user::User,
|
||||
},
|
||||
value_objects::*,
|
||||
};
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
37
crates/adapters/postgres/src/test_helpers.rs
Normal file
37
crates/adapters/postgres/src/test_helpers.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
|
||||
use domain::{
|
||||
models::{
|
||||
thought::{Thought, Visibility},
|
||||
user::User,
|
||||
},
|
||||
ports::{ThoughtRepository, UserWriter},
|
||||
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
|
||||
};
|
||||
|
||||
pub 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
|
||||
}
|
||||
|
||||
pub async fn seed_user_and_thought(pool: &sqlx::PgPool) -> (User, Thought) {
|
||||
let user = seed_user(pool, "alice", "alice@ex.com").await;
|
||||
let trepo = PgThoughtRepository::new(pool.clone());
|
||||
let t = Thought::new_local(
|
||||
ThoughtId::new(),
|
||||
user.id.clone(),
|
||||
Content::new_local("hi").unwrap(),
|
||||
None,
|
||||
Visibility::Public,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
trepo.save(&t).await.unwrap();
|
||||
(user, t)
|
||||
}
|
||||
262
crates/adapters/postgres/src/thought.rs
Normal file
262
crates/adapters/postgres/src/thought.rs
Normal file
@@ -0,0 +1,262 @@
|
||||
use crate::db_error::IntoDbResult;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
feed::{PageParams, Paginated},
|
||||
thought::{Thought, Visibility},
|
||||
},
|
||||
ports::ThoughtRepository,
|
||||
value_objects::{Content, ThoughtId, UserId},
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
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 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 TryFrom<ThoughtRow> for Thought {
|
||||
type Error = DomainError;
|
||||
fn try_from(r: ThoughtRow) -> Result<Self, DomainError> {
|
||||
Ok(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),
|
||||
visibility: Visibility::from_db_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,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,visibility,content_warning,sensitive,local,created_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
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.visibility.as_str())
|
||||
.bind(&t.content_warning)
|
||||
.bind(t.sensitive)
|
||||
.bind(t.local)
|
||||
.bind(t.created_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
.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
|
||||
.into_domain()
|
||||
.and_then(|o| o.map(Thought::try_from).transpose())
|
||||
}
|
||||
|
||||
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
|
||||
.into_domain()?;
|
||||
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
|
||||
.into_domain()
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn get_thread(&self, id: &ThoughtId) -> Result<Vec<Thought>, DomainError> {
|
||||
// Recursive CTE: fetches the root thought and all nested replies at any depth.
|
||||
sqlx::query_as::<_, ThoughtRow>(
|
||||
"WITH RECURSIVE thread AS (
|
||||
SELECT id,user_id,content,in_reply_to_id,
|
||||
visibility,content_warning,sensitive,local,created_at,updated_at
|
||||
FROM thoughts WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT t.id,t.user_id,t.content,t.in_reply_to_id,
|
||||
t.visibility,t.content_warning,t.sensitive,t.local,t.created_at,t.updated_at
|
||||
FROM thoughts t JOIN thread ON t.in_reply_to_id = thread.id
|
||||
)
|
||||
SELECT * FROM thread ORDER BY created_at ASC",
|
||||
)
|
||||
.bind(id.as_uuid())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
.and_then(|rows| rows.into_iter().map(Thought::try_from).collect())
|
||||
}
|
||||
|
||||
async fn list_by_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
page: &PageParams,
|
||||
) -> Result<Paginated<Thought>, DomainError> {
|
||||
let uid = user_id.as_uuid();
|
||||
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts WHERE user_id = $1")
|
||||
.bind(uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
|
||||
let rows = sqlx::query_as::<_, ThoughtRow>(&format!(
|
||||
"{THOUGHT_SELECT} WHERE user_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3"
|
||||
))
|
||||
.bind(uid)
|
||||
.bind(page.limit())
|
||||
.bind(page.offset())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
|
||||
Ok(Paginated {
|
||||
items: rows
|
||||
.into_iter()
|
||||
.map(Thought::try_from)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
total,
|
||||
page: page.page,
|
||||
per_page: page.per_page,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_helpers::seed_user;
|
||||
use domain::{
|
||||
models::thought::{Thought, Visibility},
|
||||
value_objects::*,
|
||||
};
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
155
crates/adapters/postgres/src/top_friend.rs
Normal file
155
crates/adapters/postgres/src/top_friend.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
use crate::db_error::IntoDbResult;
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{top_friend::TopFriend, user::User},
|
||||
ports::TopFriendRepository,
|
||||
value_objects::UserId,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
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.into_domain()?;
|
||||
sqlx::query("DELETE FROM top_friends WHERE user_id=$1")
|
||||
.bind(user_id.as_uuid())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.into_domain()?;
|
||||
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
|
||||
.into_domain()?;
|
||||
}
|
||||
tx.commit().await.into_domain()
|
||||
}
|
||||
|
||||
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,
|
||||
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.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
|
||||
.into_domain()?;
|
||||
|
||||
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,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
};
|
||||
(tf, u)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::user::PgUserRepository;
|
||||
use domain::ports::UserWriter;
|
||||
use domain::{models::user::User, value_objects::*};
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
352
crates/adapters/postgres/src/user.rs
Normal file
352
crates/adapters/postgres/src/user.rs
Normal file
@@ -0,0 +1,352 @@
|
||||
use crate::db_error::IntoDbResult;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::feed::{PageParams, Paginated, UserSummary},
|
||||
models::user::User,
|
||||
ports::{UserReader, UserWriter},
|
||||
value_objects::{Email, PasswordHash, UserId, Username},
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub struct PgUserRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
impl PgUserRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub 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 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,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const USER_SELECT: &str =
|
||||
"SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,\
|
||||
custom_css,local,created_at,updated_at FROM users";
|
||||
|
||||
#[async_trait]
|
||||
impl UserReader 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
|
||||
.into_domain()
|
||||
.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
|
||||
.into_domain()
|
||||
.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
|
||||
.into_domain()
|
||||
.map(|o| o.map(User::from))
|
||||
}
|
||||
|
||||
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
|
||||
.into_domain()?;
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
async fn count(&self) -> Result<i64, DomainError> {
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users WHERE local = true")
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.into_domain()
|
||||
}
|
||||
|
||||
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<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,
|
||||
total: 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,
|
||||
COUNT(*) OVER() AS total
|
||||
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
|
||||
LIMIT $1 OFFSET $2",
|
||||
)
|
||||
.bind(page.limit())
|
||||
.bind(page.offset())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
|
||||
let total = rows.first().map(|r| r.total).unwrap_or(0);
|
||||
let items = 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();
|
||||
Ok(Paginated { items, total, page: page.page, per_page: page.per_page })
|
||||
}
|
||||
|
||||
async fn find_by_ids(&self, ids: &[UserId]) -> Result<HashMap<UserId, User>, DomainError> {
|
||||
if ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let uuids: Vec<uuid::Uuid> = ids.iter().map(|id| id.as_uuid()).collect();
|
||||
let rows = sqlx::query_as::<_, UserRow>(
|
||||
&format!("{USER_SELECT} WHERE id = ANY($1)")
|
||||
)
|
||||
.bind(&uuids[..])
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.into_domain()?;
|
||||
|
||||
Ok(rows.into_iter().map(|r| {
|
||||
let user = User::from(r);
|
||||
(user.id.clone(), user)
|
||||
}).collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserWriter for PgUserRepository {
|
||||
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,created_at,updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||
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,
|
||||
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.created_at)
|
||||
.bind(user.updated_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if let sqlx::Error::Database(ref db) = e {
|
||||
if db.code().as_deref() == Some("23505") {
|
||||
return match db.constraint().unwrap_or("") {
|
||||
"users_username_key" => DomainError::UniqueViolation { field: "username" },
|
||||
"users_email_key" => DomainError::UniqueViolation { field: "email" },
|
||||
_ => DomainError::UniqueViolation { field: "unknown" },
|
||||
};
|
||||
}
|
||||
}
|
||||
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
|
||||
.into_domain()
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user