structural refactor and codebase improvements
This commit is contained in:
15
crates/adapters/sqlite-social/Cargo.toml
Normal file
15
crates/adapters/sqlite-social/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "sqlite-social"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
sqlx = { workspace = true }
|
||||
adapter-common = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
272
crates/adapters/sqlite-social/src/ap_content.rs
Normal file
272
crates/adapters/sqlite-social/src/ap_content.rs
Normal file
@@ -0,0 +1,272 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
DiaryEntry, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry,
|
||||
WatchlistWithMovie,
|
||||
},
|
||||
ports::LocalApContentQuery,
|
||||
value_objects::{
|
||||
Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
||||
ReviewId, UserId, WatchlistEntryId,
|
||||
},
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub struct SqliteApContentQuery {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteApContentQuery {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Local row types ──────────────────────────────────────────────────────────
|
||||
|
||||
use adapter_common::{parse_datetime, parse_uuid};
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct MovieRow {
|
||||
id: String,
|
||||
external_metadata_id: Option<String>,
|
||||
title: String,
|
||||
release_year: i64,
|
||||
director: Option<String>,
|
||||
poster_path: Option<String>,
|
||||
}
|
||||
|
||||
impl MovieRow {
|
||||
fn into_domain(self) -> Result<Movie, DomainError> {
|
||||
let id = MovieId::from_uuid(parse_uuid(&self.id)?);
|
||||
let external_metadata_id = self
|
||||
.external_metadata_id
|
||||
.map(ExternalMetadataId::new)
|
||||
.transpose()?;
|
||||
let title = MovieTitle::new(self.title)?;
|
||||
let release_year = ReleaseYear::new(self.release_year as u16)?;
|
||||
let poster_path = self.poster_path.map(PosterPath::new).transpose()?;
|
||||
Ok(Movie::from_persistence(
|
||||
id,
|
||||
external_metadata_id,
|
||||
title,
|
||||
release_year,
|
||||
self.director,
|
||||
poster_path,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ReviewRow {
|
||||
id: String,
|
||||
movie_id: String,
|
||||
user_id: String,
|
||||
rating: i64,
|
||||
comment: Option<String>,
|
||||
watched_at: String,
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
watch_medium: Option<String>,
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
fn into_domain(self) -> Result<Review, DomainError> {
|
||||
let id = ReviewId::from_uuid(parse_uuid(&self.id)?);
|
||||
let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?);
|
||||
let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?);
|
||||
let rating = Rating::new(self.rating as u8)?;
|
||||
let comment = self.comment.map(Comment::new).transpose()?;
|
||||
let watched_at = parse_datetime(&self.watched_at)?;
|
||||
let created_at = parse_datetime(&self.created_at)?;
|
||||
let source = match self.remote_actor_url {
|
||||
None => ReviewSource::Local,
|
||||
Some(url) => ReviewSource::Remote { actor_url: url },
|
||||
};
|
||||
let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?;
|
||||
Ok(Review::from_persistence(PersistedReview {
|
||||
id,
|
||||
movie_id,
|
||||
user_id,
|
||||
rating,
|
||||
comment,
|
||||
watched_at,
|
||||
created_at,
|
||||
source,
|
||||
watch_medium,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DiaryRow {
|
||||
id: String,
|
||||
external_metadata_id: Option<String>,
|
||||
title: String,
|
||||
release_year: i64,
|
||||
director: Option<String>,
|
||||
poster_path: Option<String>,
|
||||
review_id: String,
|
||||
movie_id: String,
|
||||
user_id: String,
|
||||
rating: i64,
|
||||
comment: Option<String>,
|
||||
watched_at: String,
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
watch_medium: Option<String>,
|
||||
}
|
||||
|
||||
impl DiaryRow {
|
||||
fn into_domain(self) -> Result<DiaryEntry, DomainError> {
|
||||
let movie = MovieRow {
|
||||
id: self.id,
|
||||
external_metadata_id: self.external_metadata_id,
|
||||
title: self.title,
|
||||
release_year: self.release_year,
|
||||
director: self.director,
|
||||
poster_path: self.poster_path,
|
||||
}
|
||||
.into_domain()?;
|
||||
let review = ReviewRow {
|
||||
id: self.review_id,
|
||||
movie_id: self.movie_id,
|
||||
user_id: self.user_id,
|
||||
rating: self.rating,
|
||||
comment: self.comment,
|
||||
watched_at: self.watched_at,
|
||||
created_at: self.created_at,
|
||||
remote_actor_url: self.remote_actor_url,
|
||||
watch_medium: self.watch_medium,
|
||||
}
|
||||
.into_domain()?;
|
||||
Ok(DiaryEntry::new(movie, review))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct WatchlistRow {
|
||||
id: String,
|
||||
user_id: String,
|
||||
movie_id: String,
|
||||
added_at: String,
|
||||
m_id: String,
|
||||
external_metadata_id: Option<String>,
|
||||
title: String,
|
||||
release_year: i64,
|
||||
director: Option<String>,
|
||||
poster_path: Option<String>,
|
||||
}
|
||||
|
||||
impl WatchlistRow {
|
||||
fn into_domain(self) -> Result<WatchlistWithMovie, DomainError> {
|
||||
let entry = WatchlistEntry {
|
||||
id: WatchlistEntryId::from_uuid(parse_uuid(&self.id)?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&self.user_id)?),
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&self.movie_id)?),
|
||||
added_at: parse_datetime(&self.added_at)?,
|
||||
};
|
||||
let movie = MovieRow {
|
||||
id: self.m_id,
|
||||
external_metadata_id: self.external_metadata_id,
|
||||
title: self.title,
|
||||
release_year: self.release_year,
|
||||
director: self.director,
|
||||
poster_path: self.poster_path,
|
||||
}
|
||||
.into_domain()?;
|
||||
Ok(WatchlistWithMovie { entry, movie })
|
||||
}
|
||||
}
|
||||
|
||||
// ── LocalApContentQuery impl ─────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl LocalApContentQuery for SqliteApContentQuery {
|
||||
async fn get_local_watchlist_for_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<WatchlistWithMovie>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let rows: Vec<WatchlistRow> = sqlx::query_as(
|
||||
"SELECT w.id, w.user_id, w.movie_id, w.added_at,
|
||||
m.id AS m_id, m.external_metadata_id, m.title, m.release_year,
|
||||
m.director, m.poster_path
|
||||
FROM watchlist_entries w
|
||||
JOIN movies m ON m.id = w.movie_id
|
||||
WHERE w.user_id = ?
|
||||
ORDER BY w.added_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
rows.into_iter().map(WatchlistRow::into_domain).collect()
|
||||
}
|
||||
|
||||
async fn get_local_reviews_for_movie(
|
||||
&self,
|
||||
movie_id: &MovieId,
|
||||
) -> Result<Vec<DiaryEntry>, DomainError> {
|
||||
let mid = movie_id.value().to_string();
|
||||
let rows = sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.movie_id = ? AND r.remote_actor_url IS NULL
|
||||
ORDER BY r.created_at DESC",
|
||||
)
|
||||
.bind(&mid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||
}
|
||||
|
||||
async fn get_local_reviews_page(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
before: Option<chrono::NaiveDateTime>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<DiaryEntry>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let limit_i64 = limit as i64;
|
||||
|
||||
let rows = if let Some(before_ts) = before {
|
||||
let ts = before_ts.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ? AND r.remote_actor_url IS NULL AND r.watched_at < ?
|
||||
ORDER BY r.watched_at DESC
|
||||
LIMIT ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(&ts)
|
||||
.bind(limit_i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
} else {
|
||||
sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ? AND r.remote_actor_url IS NULL
|
||||
ORDER BY r.watched_at DESC
|
||||
LIMIT ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(limit_i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
};
|
||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||
}
|
||||
}
|
||||
58
crates/adapters/sqlite-social/src/federated_profile.rs
Normal file
58
crates/adapters/sqlite-social/src/federated_profile.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, models::FederatedProfile, ports::FederatedProfileQuery};
|
||||
use sqlx::Row;
|
||||
|
||||
use super::SqliteSocialRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl FederatedProfileQuery for SqliteSocialRepository {
|
||||
async fn get_federated_profile(
|
||||
&self,
|
||||
synthetic_user_id: uuid::Uuid,
|
||||
) -> Result<Option<FederatedProfile>, DomainError> {
|
||||
let uid = synthetic_user_id.to_string();
|
||||
|
||||
let actor_url: Option<String> = sqlx::query_scalar(
|
||||
"SELECT remote_actor_url FROM reviews
|
||||
WHERE user_id = ? AND remote_actor_url IS NOT NULL
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
let actor_url = match actor_url {
|
||||
Some(url) => url,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT handle, display_name, bio, avatar_url, banner_url
|
||||
FROM ap_remote_actors WHERE url = ?",
|
||||
)
|
||||
.bind(&actor_url)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
match row {
|
||||
Some(r) => Ok(Some(FederatedProfile {
|
||||
actor_url,
|
||||
handle: r.get("handle"),
|
||||
display_name: r.try_get("display_name").ok().flatten(),
|
||||
bio: r.try_get("bio").ok().flatten(),
|
||||
avatar_url: r.try_get("avatar_url").ok().flatten(),
|
||||
banner_url: r.try_get("banner_url").ok().flatten(),
|
||||
})),
|
||||
None => Ok(Some(FederatedProfile {
|
||||
handle: actor_url.clone(),
|
||||
actor_url,
|
||||
display_name: None,
|
||||
bio: None,
|
||||
avatar_url: None,
|
||||
banner_url: None,
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
357
crates/adapters/sqlite-social/src/follow_repository.rs
Normal file
357
crates/adapters/sqlite-social/src/follow_repository.rs
Normal file
@@ -0,0 +1,357 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
value_objects::{FollowRelation, FollowStatus, InstanceIdentity, SocialActor, SocialIdentity},
|
||||
};
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::SqliteSocialRepository;
|
||||
use adapter_common::datetime_to_str;
|
||||
|
||||
fn follow_status_to_str(status: &FollowStatus) -> &'static str {
|
||||
match status {
|
||||
FollowStatus::Pending => "pending",
|
||||
FollowStatus::Accepted => "accepted",
|
||||
FollowStatus::Rejected => "rejected",
|
||||
}
|
||||
}
|
||||
|
||||
fn infra_err(e: impl std::fmt::Display) -> DomainError {
|
||||
DomainError::InfrastructureError(e.to_string())
|
||||
}
|
||||
|
||||
fn follow_status_from_str(status: &str) -> Option<FollowStatus> {
|
||||
match status {
|
||||
"pending" => Some(FollowStatus::Pending),
|
||||
"accepted" => Some(FollowStatus::Accepted),
|
||||
"rejected" => Some(FollowStatus::Rejected),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl domain::ports::FollowCommand for SqliteSocialRepository {
|
||||
async fn add_follow(
|
||||
&self,
|
||||
follower_id: uuid::Uuid,
|
||||
target_actor_url: &str,
|
||||
status: FollowStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
let uid = follower_id.to_string();
|
||||
let status_str = follow_status_to_str(&status);
|
||||
let now = datetime_to_str(&Utc::now().naive_utc());
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at, status)
|
||||
VALUES (?1, ?2, '', ?3, ?4)
|
||||
ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(target_actor_url)
|
||||
.bind(&now)
|
||||
.bind(status_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_follow_status(
|
||||
&self,
|
||||
follower_id: uuid::Uuid,
|
||||
target_actor_url: &str,
|
||||
status: FollowStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
let uid = follower_id.to_string();
|
||||
let status_str = follow_status_to_str(&status);
|
||||
sqlx::query(
|
||||
"UPDATE ap_following SET status = ?1 WHERE local_user_id = ?2 AND remote_actor_url = ?3",
|
||||
)
|
||||
.bind(status_str)
|
||||
.bind(&uid)
|
||||
.bind(target_actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_follow(
|
||||
&self,
|
||||
follower_id: uuid::Uuid,
|
||||
target_actor_url: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let uid = follower_id.to_string();
|
||||
sqlx::query("DELETE FROM ap_following WHERE local_user_id = ?1 AND remote_actor_url = ?2")
|
||||
.bind(&uid)
|
||||
.bind(target_actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add_follower(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
follower_actor_url: &str,
|
||||
status: FollowStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
let uid = local_user_id.to_string();
|
||||
let status_str = follow_status_to_str(&status);
|
||||
let now = datetime_to_str(&Utc::now().naive_utc());
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id)
|
||||
VALUES (?1, ?2, ?3, ?4, '')
|
||||
ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(follower_actor_url)
|
||||
.bind(status_str)
|
||||
.bind(&now)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_follower_status(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
follower_actor_url: &str,
|
||||
status: FollowStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
let uid = local_user_id.to_string();
|
||||
let status_str = follow_status_to_str(&status);
|
||||
sqlx::query(
|
||||
"UPDATE ap_followers SET status = ?1 WHERE local_user_id = ?2 AND remote_actor_url = ?3",
|
||||
)
|
||||
.bind(status_str)
|
||||
.bind(&uid)
|
||||
.bind(follower_actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_follower_record(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
follower_actor_url: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let uid = local_user_id.to_string();
|
||||
sqlx::query("DELETE FROM ap_followers WHERE local_user_id = ?1 AND remote_actor_url = ?2")
|
||||
.bind(&uid)
|
||||
.bind(follower_actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn social_actor_from_row(
|
||||
row: &sqlx::sqlite::SqliteRow,
|
||||
instance: &InstanceIdentity,
|
||||
) -> SocialActor {
|
||||
let actor_url: String = row.get("remote_actor_url");
|
||||
let identity = instance.identify(&actor_url);
|
||||
|
||||
let (handle, display_name, avatar_url) = match &identity {
|
||||
SocialIdentity::Local(_) => {
|
||||
let username: Option<String> = row.try_get("local_username").ok().flatten();
|
||||
let display: Option<String> = row.try_get("local_display").ok().flatten();
|
||||
let avatar: Option<String> = row
|
||||
.try_get::<Option<String>, _>("local_avatar_path")
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|p| instance.image_url_for(&p));
|
||||
let handle = username
|
||||
.as_deref()
|
||||
.map(|u| instance.handle_for(u))
|
||||
.unwrap_or_else(|| actor_url.clone());
|
||||
(handle, display, avatar)
|
||||
}
|
||||
SocialIdentity::Remote { .. } => {
|
||||
let handle: String = row
|
||||
.try_get::<Option<String>, _>("remote_handle")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| actor_url.clone());
|
||||
let display: Option<String> = row.try_get("remote_display").ok().flatten();
|
||||
let avatar: Option<String> = row.try_get("remote_avatar").ok().flatten();
|
||||
(handle, display, avatar)
|
||||
}
|
||||
};
|
||||
|
||||
SocialActor {
|
||||
identity,
|
||||
handle,
|
||||
display_name,
|
||||
avatar_url,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl domain::ports::FollowQuery for SqliteSocialRepository {
|
||||
async fn get_following(&self, user_id: uuid::Uuid) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT f.remote_actor_url,
|
||||
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
|
||||
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
|
||||
FROM ap_following f
|
||||
LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id
|
||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ?2 AND f.status = 'accepted'",
|
||||
)
|
||||
.bind(self.instance.base_url())
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| social_actor_from_row(r, &self.instance))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_followers(&self, user_id: uuid::Uuid) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT f.remote_actor_url,
|
||||
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
|
||||
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
|
||||
FROM ap_followers f
|
||||
LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id
|
||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ?2 AND f.status = 'accepted'",
|
||||
)
|
||||
.bind(self.instance.base_url())
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| social_actor_from_row(r, &self.instance))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT f.remote_actor_url,
|
||||
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
|
||||
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
|
||||
FROM ap_followers f
|
||||
LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id
|
||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ?2 AND f.status = 'pending'",
|
||||
)
|
||||
.bind(self.instance.base_url())
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| social_actor_from_row(r, &self.instance))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_pending_following(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT f.remote_actor_url,
|
||||
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
|
||||
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
|
||||
FROM ap_following f
|
||||
LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id
|
||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ?2 AND f.status = 'pending'",
|
||||
)
|
||||
.bind(self.instance.base_url())
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| social_actor_from_row(r, &self.instance))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND status = 'accepted'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn count_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'accepted'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn count_pending_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'pending'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn get_relation(
|
||||
&self,
|
||||
viewer_id: uuid::Uuid,
|
||||
target_actor_url: &str,
|
||||
) -> Result<FollowRelation, DomainError> {
|
||||
let uid = viewer_id.to_string();
|
||||
let row = sqlx::query(
|
||||
"SELECT (SELECT status FROM ap_following WHERE local_user_id = ?1 AND remote_actor_url = ?2) AS following,
|
||||
(SELECT status FROM ap_followers WHERE local_user_id = ?1 AND remote_actor_url = ?2) AS followed_by",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(target_actor_url)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
|
||||
Ok(FollowRelation {
|
||||
following: row
|
||||
.try_get::<Option<String>, _>("following")
|
||||
.map_err(infra_err)?
|
||||
.as_deref()
|
||||
.and_then(follow_status_from_str),
|
||||
followed_by: row
|
||||
.try_get::<Option<String>, _>("followed_by")
|
||||
.map_err(infra_err)?
|
||||
.as_deref()
|
||||
.and_then(follow_status_from_str),
|
||||
})
|
||||
}
|
||||
}
|
||||
42
crates/adapters/sqlite-social/src/lib.rs
Normal file
42
crates/adapters/sqlite-social/src/lib.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
mod federated_profile;
|
||||
mod follow_repository;
|
||||
mod social;
|
||||
mod watchlist;
|
||||
|
||||
pub mod ap_content;
|
||||
pub mod remote_goals;
|
||||
|
||||
pub use ap_content::SqliteApContentQuery;
|
||||
pub use remote_goals::SqliteRemoteGoalRepository;
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// SQLite-backed implementations of the *domain* social ports.
|
||||
///
|
||||
/// Deliberately separate from `sqlite-federation`: this crate knows nothing
|
||||
/// about ActivityPub, which is what allows a build with the `federation`
|
||||
/// feature off to exclude the federation stack entirely. See ADR-0009.
|
||||
///
|
||||
/// Shares the `ap_followers` / `ap_following` tables with
|
||||
/// `sqlite-federation`; neither crate owns migrations.
|
||||
pub struct SqliteSocialRepository {
|
||||
pub(crate) pool: SqlitePool,
|
||||
pub(crate) instance: domain::value_objects::InstanceIdentity,
|
||||
}
|
||||
|
||||
impl SqliteSocialRepository {
|
||||
pub fn new(pool: SqlitePool, instance: domain::value_objects::InstanceIdentity) -> Self {
|
||||
Self { pool, instance }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_federated_profile_query(
|
||||
pool: SqlitePool,
|
||||
instance: domain::value_objects::InstanceIdentity,
|
||||
) -> std::sync::Arc<dyn domain::ports::FederatedProfileQuery> {
|
||||
std::sync::Arc::new(SqliteSocialRepository::new(pool, instance))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/follow_relation_tests.rs"]
|
||||
mod follow_relation_tests;
|
||||
109
crates/adapters/sqlite-social/src/remote_goals.rs
Normal file
109
crates/adapters/sqlite-social/src/remote_goals.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::TimeZone;
|
||||
use domain::{errors::DomainError, models::RemoteGoalEntry, ports::RemoteGoalRepository};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
pub struct SqliteRemoteGoalRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteRemoteGoalRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RemoteGoalRepository for SqliteRemoteGoalRepository {
|
||||
async fn save(&self, entry: RemoteGoalEntry) -> Result<(), DomainError> {
|
||||
let received = entry.received_at.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO remote_goals \
|
||||
(ap_id, actor_url, year, target_count, current_count, received_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&entry.ap_id)
|
||||
.bind(&entry.actor_url)
|
||||
.bind(entry.year as i64)
|
||||
.bind(entry.target_count as i64)
|
||||
.bind(entry.current_count as i64)
|
||||
.bind(&received)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_by_ap_id(
|
||||
&self,
|
||||
ap_id: &str,
|
||||
target: u32,
|
||||
current: u32,
|
||||
) -> Result<(), DomainError> {
|
||||
sqlx::query("UPDATE remote_goals SET target_count = ?, current_count = ? WHERE ap_id = ?")
|
||||
.bind(target as i64)
|
||||
.bind(current as i64)
|
||||
.bind(ap_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM remote_goals WHERE ap_id = ? AND actor_url = ?")
|
||||
.bind(ap_id)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM remote_goals WHERE actor_url = ?")
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_actor_url(&self, actor_url: &str) -> Result<Vec<RemoteGoalEntry>, DomainError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT ap_id, actor_url, year, target_count, current_count, received_at \
|
||||
FROM remote_goals WHERE actor_url = ? ORDER BY year DESC",
|
||||
)
|
||||
.bind(actor_url)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
rows.iter()
|
||||
.map(|r| {
|
||||
let year: i64 = r.try_get("year").unwrap_or(0);
|
||||
let target: i64 = r.try_get("target_count").unwrap_or(0);
|
||||
let current: i64 = r.try_get("current_count").unwrap_or(0);
|
||||
let received_str: String = r.try_get("received_at").unwrap_or_default();
|
||||
let received_at =
|
||||
chrono::NaiveDateTime::parse_from_str(&received_str, "%Y-%m-%d %H:%M:%S")
|
||||
.map(|ndt| chrono::Utc.from_utc_datetime(&ndt))
|
||||
.unwrap_or_else(|_| chrono::Utc::now());
|
||||
|
||||
Ok(RemoteGoalEntry {
|
||||
ap_id: r.try_get("ap_id").unwrap_or_default(),
|
||||
actor_url: r.try_get("actor_url").unwrap_or_default(),
|
||||
year: year as u16,
|
||||
target_count: target as u32,
|
||||
current_count: current as u32,
|
||||
received_at,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
27
crates/adapters/sqlite-social/src/social.rs
Normal file
27
crates/adapters/sqlite-social/src/social.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, models::RemoteActorInfo, ports::FederationAdminQuery};
|
||||
|
||||
use super::SqliteSocialRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl FederationAdminQuery for SqliteSocialRepository {
|
||||
async fn list_all_followed_remote_actors(&self) -> Result<Vec<RemoteActorInfo>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, (String, String, Option<String>)>(
|
||||
"SELECT DISTINCT ar.url, ar.handle, ar.display_name
|
||||
FROM ap_remote_actors ar
|
||||
JOIN ap_following f ON f.remote_actor_url = ar.url
|
||||
WHERE f.status = 'accepted'",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(url, handle, display_name)| RemoteActorInfo {
|
||||
url,
|
||||
handle,
|
||||
display_name,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
253
crates/adapters/sqlite-social/src/tests/follow_relation_tests.rs
Normal file
253
crates/adapters/sqlite-social/src/tests/follow_relation_tests.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
use super::*;
|
||||
use domain::ports::{FederationAdminQuery, FollowQuery};
|
||||
use domain::value_objects::{FollowStatus, InstanceIdentity, SocialIdentity};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
async fn test_pool() -> SqlitePool {
|
||||
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
for ddl in [
|
||||
"CREATE TABLE ap_following (local_user_id TEXT NOT NULL, remote_actor_url TEXT NOT NULL,
|
||||
follow_activity_id TEXT NOT NULL, created_at TEXT, status TEXT NOT NULL,
|
||||
PRIMARY KEY (local_user_id, remote_actor_url))",
|
||||
"CREATE TABLE ap_followers (local_user_id TEXT NOT NULL, remote_actor_url TEXT NOT NULL,
|
||||
follow_activity_id TEXT NOT NULL, created_at TEXT, status TEXT NOT NULL,
|
||||
PRIMARY KEY (local_user_id, remote_actor_url))",
|
||||
"CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL,
|
||||
display_name TEXT, avatar_path TEXT)",
|
||||
"CREATE TABLE ap_remote_actors (url TEXT PRIMARY KEY, handle TEXT NOT NULL,
|
||||
display_name TEXT, avatar_url TEXT)",
|
||||
] {
|
||||
sqlx::query(ddl).execute(&pool).await.unwrap();
|
||||
}
|
||||
pool
|
||||
}
|
||||
|
||||
fn repo(pool: SqlitePool) -> SqliteSocialRepository {
|
||||
SqliteSocialRepository::new(pool, InstanceIdentity::new("https://md.example"))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_relation_returns_no_edges_for_strangers() {
|
||||
let r = repo(test_pool().await);
|
||||
let rel = r
|
||||
.get_relation(uuid::Uuid::new_v4(), "https://other.example/users/bob")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rel.following, None);
|
||||
assert_eq!(rel.followed_by, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_relation_reads_following_direction_only_from_ap_following() {
|
||||
let pool = test_pool().await;
|
||||
let viewer = uuid::Uuid::new_v4();
|
||||
let target = "https://other.example/users/bob";
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
|
||||
VALUES (?1, ?2, '', 'pending')",
|
||||
)
|
||||
.bind(viewer.to_string())
|
||||
.bind(target)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let rel = repo(pool).get_relation(viewer, target).await.unwrap();
|
||||
|
||||
assert_eq!(rel.following, Some(FollowStatus::Pending));
|
||||
assert_eq!(
|
||||
rel.followed_by, None,
|
||||
"an ap_following row must not populate followed_by"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_relation_reads_followed_by_from_ap_followers_including_rejected() {
|
||||
let pool = test_pool().await;
|
||||
let owner = uuid::Uuid::new_v4();
|
||||
let requester = "https://other.example/users/carol";
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_followers (local_user_id, remote_actor_url, follow_activity_id, status)
|
||||
VALUES (?1, ?2, '', 'rejected')",
|
||||
)
|
||||
.bind(owner.to_string())
|
||||
.bind(requester)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let rel = repo(pool).get_relation(owner, requester).await.unwrap();
|
||||
|
||||
assert_eq!(rel.followed_by, Some(FollowStatus::Rejected));
|
||||
assert_eq!(rel.following, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_relation_treats_unknown_status_as_no_edge() {
|
||||
let pool = test_pool().await;
|
||||
let viewer = uuid::Uuid::new_v4();
|
||||
let target = "https://other.example/users/dave";
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
|
||||
VALUES (?1, ?2, '', 'not-a-real-status')",
|
||||
)
|
||||
.bind(viewer.to_string())
|
||||
.bind(target)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let rel = repo(pool).get_relation(viewer, target).await.unwrap();
|
||||
|
||||
assert_eq!(rel.following, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_pending_following_returns_only_pending_rows() {
|
||||
let pool = test_pool().await;
|
||||
let viewer = uuid::Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
|
||||
VALUES (?1, 'https://other.example/users/pending', '', 'pending'),
|
||||
(?1, 'https://other.example/users/accepted', '', 'accepted')",
|
||||
)
|
||||
.bind(viewer.to_string())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let actors = FollowQuery::get_pending_following(&repo(pool), viewer)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
actors.len(),
|
||||
1,
|
||||
"accepted rows must not appear in pending_following"
|
||||
);
|
||||
// The handle-fallback-on-join-miss behavior is covered by
|
||||
// `remote_actor_with_no_cached_row_falls_back_to_its_actor_url`; this test
|
||||
// only needs to check pending-row filtering, so it asserts on identity.
|
||||
assert_eq!(
|
||||
actors[0].identity,
|
||||
SocialIdentity::Remote {
|
||||
actor_url: "https://other.example/users/pending".to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_actor_with_no_cached_row_falls_back_to_its_actor_url() {
|
||||
let pool = test_pool().await;
|
||||
let viewer = uuid::Uuid::new_v4();
|
||||
let orphan = "https://other.example/users/uncached";
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
|
||||
VALUES (?1, ?2, '', 'pending')",
|
||||
)
|
||||
.bind(viewer.to_string())
|
||||
.bind(orphan)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
// deliberately NO ap_remote_actors row for `orphan`
|
||||
|
||||
let actors = FollowQuery::get_pending_following(&repo(pool), viewer)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(actors.len(), 1);
|
||||
assert_eq!(
|
||||
actors[0].handle, orphan,
|
||||
"with no cached actor, handle must fall back to the actor url, not render empty"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn count_pending_followers_counts_only_pending() {
|
||||
let pool = test_pool().await;
|
||||
let owner = uuid::Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_followers (local_user_id, remote_actor_url, follow_activity_id, status)
|
||||
VALUES (?1, 'https://other.example/users/a', '', 'pending'),
|
||||
(?1, 'https://other.example/users/b', '', 'pending'),
|
||||
(?1, 'https://other.example/users/c', '', 'accepted'),
|
||||
(?1, 'https://other.example/users/d', '', 'rejected')",
|
||||
)
|
||||
.bind(owner.to_string())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let n = FollowQuery::count_pending_followers(&repo(pool), owner)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
n, 2,
|
||||
"only pending rows count; accepted and rejected must not"
|
||||
);
|
||||
}
|
||||
|
||||
async fn setup_admin_query_db(pool: &SqlitePool) {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS ap_remote_actors (
|
||||
url TEXT PRIMARY KEY,
|
||||
handle TEXT NOT NULL,
|
||||
inbox_url TEXT NOT NULL,
|
||||
shared_inbox_url TEXT,
|
||||
display_name TEXT,
|
||||
avatar_url TEXT,
|
||||
fetched_at TEXT NOT NULL
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS ap_following (
|
||||
local_user_id TEXT NOT NULL,
|
||||
remote_actor_url TEXT NOT NULL,
|
||||
follow_activity_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
PRIMARY KEY (local_user_id, remote_actor_url)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_all_followed_remote_actors_deduplicates() {
|
||||
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
||||
setup_admin_query_db(&pool).await;
|
||||
let repo =
|
||||
SqliteSocialRepository::new(pool.clone(), InstanceIdentity::new("https://localhost"));
|
||||
let user1 = uuid::Uuid::new_v4();
|
||||
let user2 = uuid::Uuid::new_v4();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_remote_actors (url, handle, inbox_url, fetched_at, display_name)
|
||||
VALUES ('https://other.social/users/alice', 'alice@other.social', 'https://other.social/inbox', '2024-01-01', 'Alice')",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
|
||||
VALUES (?, 'https://other.social/users/alice', 'act1', 'accepted'),
|
||||
(?, 'https://other.social/users/alice', 'act2', 'accepted')",
|
||||
)
|
||||
.bind(user1.to_string())
|
||||
.bind(user2.to_string())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let actors = repo.list_all_followed_remote_actors().await.unwrap();
|
||||
assert_eq!(actors.len(), 1);
|
||||
assert_eq!(actors[0].handle, "alice@other.social");
|
||||
}
|
||||
100
crates/adapters/sqlite-social/src/watchlist.rs
Normal file
100
crates/adapters/sqlite-social/src/watchlist.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, models::RemoteWatchlistEntry, ports::RemoteWatchlistRepository};
|
||||
use sqlx::Row;
|
||||
|
||||
use super::SqliteSocialRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl RemoteWatchlistRepository for SqliteSocialRepository {
|
||||
async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_remote_watchlist_entries \
|
||||
(ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?) \
|
||||
ON CONFLICT(ap_id) DO UPDATE SET \
|
||||
movie_title=excluded.movie_title, release_year=excluded.release_year, \
|
||||
external_metadata_id=excluded.external_metadata_id, poster_url=excluded.poster_url",
|
||||
)
|
||||
.bind(&entry.ap_id).bind(&entry.actor_url).bind(&entry.movie_title)
|
||||
.bind(entry.release_year as i64).bind(&entry.external_metadata_id).bind(&entry.poster_url)
|
||||
.bind(entry.added_at.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
.execute(&self.pool).await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE ap_id = ? AND actor_url = ?")
|
||||
.bind(ap_id)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_actor_url(
|
||||
&self,
|
||||
actor_url: &str,
|
||||
) -> Result<Vec<RemoteWatchlistEntry>, DomainError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at \
|
||||
FROM ap_remote_watchlist_entries WHERE actor_url = ? ORDER BY added_at DESC",
|
||||
).bind(actor_url).fetch_all(&self.pool).await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
let added_at_str: String = row.try_get("added_at").unwrap_or_default();
|
||||
let added_at =
|
||||
chrono::NaiveDateTime::parse_from_str(&added_at_str, "%Y-%m-%d %H:%M:%S")
|
||||
.map(|dt| {
|
||||
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
|
||||
dt,
|
||||
chrono::Utc,
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|_| chrono::Utc::now());
|
||||
Ok(RemoteWatchlistEntry {
|
||||
ap_id: row.try_get("ap_id").unwrap_or_default(),
|
||||
actor_url: row.try_get("actor_url").unwrap_or_default(),
|
||||
movie_title: row.try_get("movie_title").unwrap_or_default(),
|
||||
release_year: row.try_get::<i64, _>("release_year").unwrap_or(0) as u16,
|
||||
external_metadata_id: row.try_get("external_metadata_id").ok().flatten(),
|
||||
poster_url: row.try_get("poster_url").ok().flatten(),
|
||||
added_at,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE actor_url = ?")
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_derived_uuid(
|
||||
&self,
|
||||
uuid: uuid::Uuid,
|
||||
) -> Result<Vec<RemoteWatchlistEntry>, DomainError> {
|
||||
let actors: Vec<String> =
|
||||
sqlx::query("SELECT DISTINCT actor_url FROM ap_remote_watchlist_entries")
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
.into_iter()
|
||||
.filter_map(|row| row.try_get::<String, _>("actor_url").ok())
|
||||
.collect();
|
||||
let target = actors
|
||||
.into_iter()
|
||||
.find(|url| uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()) == uuid);
|
||||
match target {
|
||||
None => Ok(vec![]),
|
||||
Some(actor_url) => self.get_by_actor_url(&actor_url).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user