structural refactor and codebase improvements
This commit is contained in:
@@ -6,24 +6,9 @@ description = "Self-hosted movie diary with REST API and ActivityPub federation"
|
||||
license = "MIT"
|
||||
|
||||
[features]
|
||||
default = ["sqlite", "sqlite-federation"]
|
||||
sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search", "infra-wiring/sqlite"]
|
||||
postgres = ["dep:postgres", "dep:postgres-event-queue", "dep:postgres-search", "infra-wiring/postgres"]
|
||||
nats = ["dep:nats", "infra-wiring/nats"]
|
||||
default = ["federation"]
|
||||
# Meta-feature: true when any federation adapter is active — keeps all #[cfg(feature = "federation")] gates working
|
||||
federation = ["application/federation"]
|
||||
sqlite-federation = [
|
||||
"sqlite",
|
||||
"dep:sqlite-federation",
|
||||
"dep:activitypub",
|
||||
"federation",
|
||||
]
|
||||
postgres-federation = [
|
||||
"postgres",
|
||||
"dep:postgres-federation",
|
||||
"dep:activitypub",
|
||||
"federation",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
tower-http = { version = "0.6.8", features = ["cors", "fs", "trace", "tracing"] }
|
||||
@@ -47,38 +32,14 @@ futures = { workspace = true }
|
||||
api-types = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
auth = { workspace = true }
|
||||
metadata = { workspace = true }
|
||||
poster-fetcher = { workspace = true }
|
||||
object-storage = { workspace = true }
|
||||
template-askama = { workspace = true }
|
||||
nats = { workspace = true, optional = true }
|
||||
rss = { workspace = true }
|
||||
export = { workspace = true }
|
||||
importer = { workspace = true }
|
||||
jellyfin = { workspace = true }
|
||||
plex = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
infra-wiring = { workspace = true }
|
||||
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] }
|
||||
utoipa-scalar = { version = "0.3.0", features = ["axum"], default-features = false }
|
||||
utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] }
|
||||
|
||||
# Optional — database backends
|
||||
sqlite = { workspace = true, optional = true }
|
||||
postgres = { workspace = true, optional = true }
|
||||
sqlite-event-queue = { workspace = true, optional = true }
|
||||
postgres-event-queue = { workspace = true, optional = true }
|
||||
sqlite-search = { workspace = true, optional = true }
|
||||
postgres-search = { workspace = true, optional = true }
|
||||
|
||||
# Optional — federation
|
||||
activitypub = { workspace = true, optional = true }
|
||||
sqlite-federation = { workspace = true, optional = true }
|
||||
postgres-federation = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
bytes = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
http-body-util = "0.1"
|
||||
domain = { workspace = true, features = ["test-helpers"] }
|
||||
composition = { workspace = true }
|
||||
|
||||
@@ -1,73 +1,15 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{
|
||||
AuthService, DiaryExporter, DiaryQuery, DocumentParser, EventPublisher, FederatedProfileQuery,
|
||||
FederationAdminQuery, GoalCommand, GoalQuery, ImportProfileRepository, ImportSessionRepository,
|
||||
MetadataClient, MovieCommand, MovieProfileRepository, MovieQuery, ObjectStorage,
|
||||
PasswordHasher, PersonCommand, PersonEnrichmentClient, PersonQuery, PosterFetcherClient,
|
||||
RefreshSessionRepository, RemoteGoalRepository, RemoteWatchlistRepository, ReviewRepository,
|
||||
SearchCommand, SearchPort, SocialCommand, SocialQuery, StatsRepository,
|
||||
UserProfileFieldsRepository, UserRepository, UserSettingsRepository, WatchEventCommand,
|
||||
WatchEventQuery, WatchlistRepository, WebhookTokenRepository, WrapUpRepository,
|
||||
WrapUpStatsQuery,
|
||||
};
|
||||
|
||||
use application::config::AppConfig;
|
||||
use application::ports::ReviewLogger;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Repositories {
|
||||
pub movie_command: Arc<dyn MovieCommand>,
|
||||
pub movie_query: Arc<dyn MovieQuery>,
|
||||
pub review: Arc<dyn ReviewRepository>,
|
||||
pub diary: Arc<dyn DiaryQuery>,
|
||||
pub stats: Arc<dyn StatsRepository>,
|
||||
pub user: Arc<dyn UserRepository>,
|
||||
pub import_session: Arc<dyn ImportSessionRepository>,
|
||||
pub import_profile: Arc<dyn ImportProfileRepository>,
|
||||
pub movie_profile: Arc<dyn MovieProfileRepository>,
|
||||
pub watchlist: Arc<dyn WatchlistRepository>,
|
||||
pub watch_event_command: Arc<dyn WatchEventCommand>,
|
||||
pub watch_event_query: Arc<dyn WatchEventQuery>,
|
||||
pub webhook_token: Arc<dyn WebhookTokenRepository>,
|
||||
pub person_command: Arc<dyn PersonCommand>,
|
||||
pub person_query: Arc<dyn PersonQuery>,
|
||||
pub search_port: Arc<dyn SearchPort>,
|
||||
pub search_command: Arc<dyn SearchCommand>,
|
||||
pub profile_fields: Arc<dyn UserProfileFieldsRepository>,
|
||||
pub remote_watchlist: Arc<dyn RemoteWatchlistRepository>,
|
||||
pub social_command: Arc<dyn SocialCommand>,
|
||||
pub social_query_unified: Arc<dyn SocialQuery>,
|
||||
pub federation_admin: Arc<dyn FederationAdminQuery>,
|
||||
pub wrapup_stats: Arc<dyn WrapUpStatsQuery>,
|
||||
pub wrapup_repo: Arc<dyn WrapUpRepository>,
|
||||
pub goal_command: Arc<dyn GoalCommand>,
|
||||
pub goal_query: Arc<dyn GoalQuery>,
|
||||
pub user_settings: Arc<dyn UserSettingsRepository>,
|
||||
pub remote_goal: Arc<dyn RemoteGoalRepository>,
|
||||
pub refresh_session: Arc<dyn RefreshSessionRepository>,
|
||||
pub federated_profile: Option<Arc<dyn FederatedProfileQuery>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Services {
|
||||
pub auth: Arc<dyn AuthService>,
|
||||
pub password_hasher: Arc<dyn PasswordHasher>,
|
||||
pub metadata: Arc<dyn MetadataClient>,
|
||||
pub poster_fetcher: Arc<dyn PosterFetcherClient>,
|
||||
pub object_storage: Arc<dyn ObjectStorage>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
pub diary_exporter: Arc<dyn DiaryExporter>,
|
||||
pub document_parser: Arc<dyn DocumentParser>,
|
||||
pub review_logger: Arc<dyn ReviewLogger>,
|
||||
pub person_enrichment: Option<Arc<dyn PersonEnrichmentClient>>,
|
||||
#[cfg(feature = "federation")]
|
||||
pub ap_service: Arc<dyn activitypub::ActivityPubPort>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppContext {
|
||||
pub repos: Repositories,
|
||||
pub services: Services,
|
||||
pub deps: Arc<application::Deps>,
|
||||
pub services: application::Services,
|
||||
pub config: AppConfig,
|
||||
pub instance: domain::value_objects::InstanceIdentity,
|
||||
#[cfg(feature = "federation")]
|
||||
pub ap_document: Arc<dyn domain::ports::ApDocumentPort>,
|
||||
#[cfg(feature = "federation")]
|
||||
pub ap_blocklist: Arc<dyn domain::ports::InstanceBlocklistPort>,
|
||||
}
|
||||
|
||||
@@ -111,17 +111,17 @@ where
|
||||
let AuthenticatedUser(user_id) =
|
||||
AuthenticatedUser::from_request_parts(parts, state).await?;
|
||||
let app_state = AppState::from_ref(state);
|
||||
let user = app_state
|
||||
.app_ctx
|
||||
.repos
|
||||
.user
|
||||
.find_by_id(&user_id)
|
||||
.await
|
||||
.map_err(ApiError)?
|
||||
.ok_or_else(|| ApiError(DomainError::NotFound("user not found".into())))?;
|
||||
match user.role() {
|
||||
domain::models::UserRole::Admin => Ok(AdminApiUser(user_id)),
|
||||
_ => Err(ApiError(DomainError::Forbidden("admin only".into()))),
|
||||
let is_admin = application::users::authorize_admin::execute(
|
||||
&app_state.app_ctx.deps.users.authorize_admin,
|
||||
user_id.value(),
|
||||
)
|
||||
.await
|
||||
.map_err(ApiError)?
|
||||
.ok_or_else(|| ApiError(DomainError::NotFound("user not found".into())))?;
|
||||
if is_admin {
|
||||
Ok(AdminApiUser(user_id))
|
||||
} else {
|
||||
Err(ApiError(DomainError::Forbidden("admin only".into())))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,17 +139,17 @@ where
|
||||
let app_state = AppState::from_ref(state);
|
||||
let RequiredCookieUser(user_id) =
|
||||
RequiredCookieUser::from_request_parts(parts, state).await?;
|
||||
let user = app_state
|
||||
.app_ctx
|
||||
.repos
|
||||
.user
|
||||
.find_by_id(&user_id)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())?
|
||||
.ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?;
|
||||
match user.role() {
|
||||
domain::models::UserRole::Admin => Ok(AdminUser(user_id)),
|
||||
_ => Err(StatusCode::FORBIDDEN.into_response()),
|
||||
let is_admin = application::users::authorize_admin::execute(
|
||||
&app_state.app_ctx.deps.users.authorize_admin,
|
||||
user_id.value(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())?
|
||||
.ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?;
|
||||
if is_admin {
|
||||
Ok(AdminUser(user_id))
|
||||
} else {
|
||||
Err(StatusCode::FORBIDDEN.into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use domain::ports::{
|
||||
AuthService, LocalApContentQuery, MetadataClient, ObjectStorage, PasswordHasher,
|
||||
PosterFetcherClient, RefreshSessionRepository, UserProfileFieldsRepository, WatchEventCommand,
|
||||
WatchEventQuery, WebhookTokenRepository,
|
||||
};
|
||||
|
||||
pub use infra_wiring::DbPool;
|
||||
|
||||
pub struct DatabaseOutput {
|
||||
pub movie_command: Arc<dyn domain::ports::MovieCommand>,
|
||||
pub movie_query: Arc<dyn domain::ports::MovieQuery>,
|
||||
pub review: Arc<dyn domain::ports::ReviewRepository>,
|
||||
pub diary: Arc<dyn domain::ports::DiaryQuery>,
|
||||
pub stats: Arc<dyn domain::ports::StatsRepository>,
|
||||
pub user: Arc<dyn domain::ports::UserRepository>,
|
||||
pub import_session: Arc<dyn domain::ports::ImportSessionRepository>,
|
||||
pub import_profile: Arc<dyn domain::ports::ImportProfileRepository>,
|
||||
pub movie_profile: Arc<dyn domain::ports::MovieProfileRepository>,
|
||||
pub watchlist: Arc<dyn domain::ports::WatchlistRepository>,
|
||||
pub watch_event_command: Arc<dyn WatchEventCommand>,
|
||||
pub watch_event_query: Arc<dyn WatchEventQuery>,
|
||||
pub webhook_token: Arc<dyn WebhookTokenRepository>,
|
||||
pub person_command: Arc<dyn domain::ports::PersonCommand>,
|
||||
pub person_query: Arc<dyn domain::ports::PersonQuery>,
|
||||
pub search_port: Arc<dyn domain::ports::SearchPort>,
|
||||
pub search_command: Arc<dyn domain::ports::SearchCommand>,
|
||||
pub profile_fields: Arc<dyn UserProfileFieldsRepository>,
|
||||
pub ap_content: Arc<dyn LocalApContentQuery>,
|
||||
pub wrapup_stats: Arc<dyn domain::ports::WrapUpStatsQuery>,
|
||||
pub wrapup_repo: Arc<dyn domain::ports::WrapUpRepository>,
|
||||
pub goal_command: Arc<dyn domain::ports::GoalCommand>,
|
||||
pub goal_query: Arc<dyn domain::ports::GoalQuery>,
|
||||
pub user_settings: Arc<dyn domain::ports::UserSettingsRepository>,
|
||||
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
|
||||
pub remote_goal: Arc<dyn domain::ports::RemoteGoalRepository>,
|
||||
pub refresh_session: Arc<dyn RefreshSessionRepository>,
|
||||
pub db_pool: DbPool,
|
||||
}
|
||||
|
||||
pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result<DatabaseOutput> {
|
||||
match backend {
|
||||
#[cfg(feature = "postgres")]
|
||||
"postgres" => {
|
||||
let w = postgres::wire(url)
|
||||
.await
|
||||
.context("PostgreSQL connection failed")?;
|
||||
let (pc, pq) = postgres::create_person_adapter(w.pool.clone());
|
||||
let (sc, sp) = postgres_search::create_search_adapter(w.pool.clone());
|
||||
let pf = postgres::create_profile_fields_repo(w.pool.clone());
|
||||
let we = Arc::new(postgres::PostgresWatchEventRepository::new(w.pool.clone()));
|
||||
let wt: Arc<dyn WebhookTokenRepository> = Arc::new(
|
||||
postgres::PostgresWebhookTokenRepository::new(w.pool.clone()),
|
||||
);
|
||||
Ok(DatabaseOutput {
|
||||
movie_command: w.movie_command,
|
||||
movie_query: w.movie_query,
|
||||
review: w.review,
|
||||
diary: w.diary,
|
||||
stats: w.stats,
|
||||
user: w.user,
|
||||
import_session: w.import_session,
|
||||
import_profile: w.import_profile,
|
||||
movie_profile: w.movie_profile,
|
||||
watchlist: w.watchlist,
|
||||
watch_event_command: we.clone() as _,
|
||||
watch_event_query: we as _,
|
||||
webhook_token: wt,
|
||||
person_command: pc,
|
||||
person_query: pq,
|
||||
search_port: sp,
|
||||
search_command: sc,
|
||||
profile_fields: pf,
|
||||
ap_content: w.ap_content,
|
||||
wrapup_stats: w.wrapup_stats,
|
||||
wrapup_repo: w.wrapup_repo,
|
||||
goal_command: w.goal_command,
|
||||
goal_query: w.goal_query,
|
||||
user_settings: w.user_settings,
|
||||
federation_settings: w.federation_settings,
|
||||
remote_goal: w.remote_goal,
|
||||
refresh_session: Arc::new(postgres::PostgresRefreshSessionAdapter::new(
|
||||
w.pool.clone(),
|
||||
)) as _,
|
||||
db_pool: DbPool::Postgres(w.pool),
|
||||
})
|
||||
}
|
||||
#[cfg(feature = "sqlite")]
|
||||
_ => {
|
||||
let w = sqlite::wire(url)
|
||||
.await
|
||||
.context("SQLite connection failed")?;
|
||||
let (pc, pq) = sqlite::create_person_adapter(w.pool.clone());
|
||||
let (sc, sp) = sqlite_search::create_search_adapter(w.pool.clone());
|
||||
let pf = sqlite::create_profile_fields_repo(w.pool.clone());
|
||||
let we = Arc::new(sqlite::SqliteWatchEventRepository::new(w.pool.clone()));
|
||||
let wt: Arc<dyn WebhookTokenRepository> =
|
||||
Arc::new(sqlite::SqliteWebhookTokenRepository::new(w.pool.clone()));
|
||||
Ok(DatabaseOutput {
|
||||
movie_command: w.movie_command,
|
||||
movie_query: w.movie_query,
|
||||
review: w.review,
|
||||
diary: w.diary,
|
||||
stats: w.stats,
|
||||
user: w.user,
|
||||
import_session: w.import_session,
|
||||
import_profile: w.import_profile,
|
||||
movie_profile: w.movie_profile,
|
||||
watchlist: w.watchlist,
|
||||
watch_event_command: we.clone() as _,
|
||||
watch_event_query: we as _,
|
||||
webhook_token: wt,
|
||||
person_command: pc,
|
||||
person_query: pq,
|
||||
search_port: sp,
|
||||
search_command: sc,
|
||||
profile_fields: pf,
|
||||
ap_content: w.ap_content,
|
||||
wrapup_stats: w.wrapup_stats,
|
||||
wrapup_repo: w.wrapup_repo,
|
||||
goal_command: w.goal_command,
|
||||
goal_query: w.goal_query,
|
||||
user_settings: w.user_settings,
|
||||
federation_settings: w.federation_settings,
|
||||
remote_goal: w.remote_goal,
|
||||
refresh_session: Arc::new(sqlite::SqliteRefreshSessionAdapter::new(w.pool.clone()))
|
||||
as _,
|
||||
db_pool: DbPool::Sqlite(w.pool),
|
||||
})
|
||||
}
|
||||
#[cfg(not(feature = "sqlite"))]
|
||||
_ => anyhow::bail!(
|
||||
"DATABASE_BACKEND={backend} is not supported by this build (enable sqlite or postgres feature)"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_auth_adapters() -> anyhow::Result<(Arc<dyn AuthService>, Arc<dyn PasswordHasher>)> {
|
||||
auth::create()
|
||||
}
|
||||
|
||||
pub fn build_metadata_client() -> anyhow::Result<Arc<dyn MetadataClient>> {
|
||||
metadata::create()
|
||||
}
|
||||
|
||||
pub fn build_poster_fetcher() -> anyhow::Result<Arc<dyn PosterFetcherClient>> {
|
||||
poster_fetcher::create()
|
||||
}
|
||||
|
||||
pub fn build_object_storage() -> anyhow::Result<Arc<dyn ObjectStorage>> {
|
||||
object_storage::create()
|
||||
}
|
||||
|
||||
pub fn build_profile_fields_repo(
|
||||
pool: &DbPool,
|
||||
) -> anyhow::Result<Arc<dyn UserProfileFieldsRepository>> {
|
||||
match pool {
|
||||
#[cfg(feature = "postgres")]
|
||||
DbPool::Postgres(pool) => Ok(postgres::create_profile_fields_repo(pool.clone())),
|
||||
#[cfg(feature = "sqlite")]
|
||||
DbPool::Sqlite(pool) => Ok(sqlite::create_profile_fields_repo(pool.clone())),
|
||||
#[cfg(not(feature = "sqlite"))]
|
||||
_ => anyhow::bail!("no profile fields repo for this backend"),
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,7 @@ use axum::{
|
||||
use chrono::Utc;
|
||||
|
||||
use application::auth::{
|
||||
commands::RegisterCommand,
|
||||
deps::{LoginDeps, RefreshDeps, RegisterAndLoginDeps, RegisterDeps},
|
||||
login as login_uc,
|
||||
queries::LoginCommand,
|
||||
register as register_uc,
|
||||
commands::RegisterCommand, login as login_uc, queries::LoginCommand, register as register_uc,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
@@ -56,15 +52,8 @@ pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<LoginResponse>, ApiError> {
|
||||
let deps = LoginDeps {
|
||||
user: state.app_ctx.repos.user.clone(),
|
||||
password_hasher: state.app_ctx.services.password_hasher.clone(),
|
||||
auth: state.app_ctx.services.auth.clone(),
|
||||
refresh_session: state.app_ctx.repos.refresh_session.clone(),
|
||||
config: state.app_ctx.config.clone(),
|
||||
};
|
||||
let result = login_uc::execute(
|
||||
&deps,
|
||||
&state.app_ctx.deps.auth.login,
|
||||
LoginCommand {
|
||||
email: req.email,
|
||||
password: req.password,
|
||||
@@ -93,13 +82,8 @@ pub async fn register(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let deps = RegisterDeps {
|
||||
user: state.app_ctx.repos.user.clone(),
|
||||
password_hasher: state.app_ctx.services.password_hasher.clone(),
|
||||
config: state.app_ctx.config.clone(),
|
||||
};
|
||||
register_uc::execute(
|
||||
&deps,
|
||||
&state.app_ctx.deps.auth.register,
|
||||
RegisterCommand {
|
||||
email: req.email,
|
||||
username: req.username,
|
||||
@@ -123,12 +107,9 @@ pub async fn refresh(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RefreshRequest>,
|
||||
) -> Result<Json<RefreshResponse>, ApiError> {
|
||||
let deps = RefreshDeps {
|
||||
refresh_session: state.app_ctx.repos.refresh_session.clone(),
|
||||
auth: state.app_ctx.services.auth.clone(),
|
||||
config: state.app_ctx.config.clone(),
|
||||
};
|
||||
let result = application::auth::refresh::execute(&deps, &req.refresh_token).await?;
|
||||
let result =
|
||||
application::auth::refresh::execute(&state.app_ctx.deps.auth.refresh, &req.refresh_token)
|
||||
.await?;
|
||||
Ok(Json(RefreshResponse {
|
||||
token: result.token,
|
||||
refresh_token: result.refresh_token,
|
||||
@@ -147,11 +128,8 @@ pub async fn api_logout(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<LogoutRequest>,
|
||||
) -> StatusCode {
|
||||
let _ = application::auth::logout::execute(
|
||||
state.app_ctx.repos.refresh_session.clone(),
|
||||
&req.refresh_token,
|
||||
)
|
||||
.await;
|
||||
let _ = application::auth::logout::execute(&state.app_ctx.deps.auth.logout, &req.refresh_token)
|
||||
.await;
|
||||
StatusCode::NO_CONTENT
|
||||
}
|
||||
|
||||
@@ -172,6 +150,7 @@ pub async fn get_login_page(
|
||||
canonical_url: format!("{}/login", state.app_ctx.config.base_url),
|
||||
csrf_token: csrf.0,
|
||||
page_rss_url: None,
|
||||
pending_follow_count: 0,
|
||||
};
|
||||
render_page(LoginTemplate {
|
||||
ctx: &ctx,
|
||||
@@ -187,15 +166,8 @@ pub async fn post_login(
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let deps = LoginDeps {
|
||||
user: state.app_ctx.repos.user.clone(),
|
||||
password_hasher: state.app_ctx.services.password_hasher.clone(),
|
||||
auth: state.app_ctx.services.auth.clone(),
|
||||
refresh_session: state.app_ctx.repos.refresh_session.clone(),
|
||||
config: state.app_ctx.config.clone(),
|
||||
};
|
||||
match login_uc::execute(
|
||||
&deps,
|
||||
&state.app_ctx.deps.auth.login,
|
||||
LoginCommand {
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
@@ -242,6 +214,7 @@ pub async fn get_register_page(
|
||||
canonical_url: format!("{}/register", state.app_ctx.config.base_url),
|
||||
csrf_token: csrf.0,
|
||||
page_rss_url: None,
|
||||
pending_follow_count: 0,
|
||||
};
|
||||
render_page(RegisterTemplate {
|
||||
ctx: &ctx,
|
||||
@@ -261,15 +234,8 @@ pub async fn post_register(
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let deps = RegisterAndLoginDeps {
|
||||
user: state.app_ctx.repos.user.clone(),
|
||||
password_hasher: state.app_ctx.services.password_hasher.clone(),
|
||||
auth: state.app_ctx.services.auth.clone(),
|
||||
refresh_session: state.app_ctx.repos.refresh_session.clone(),
|
||||
config: state.app_ctx.config.clone(),
|
||||
};
|
||||
match application::auth::register_and_login::execute(
|
||||
&deps,
|
||||
&state.app_ctx.deps.auth.register_and_login,
|
||||
application::auth::commands::RegisterAndLoginCommand {
|
||||
email: form.email,
|
||||
username: form.username,
|
||||
|
||||
@@ -8,9 +8,7 @@ use uuid::Uuid;
|
||||
|
||||
use application::diary::{
|
||||
commands::{DeleteReviewCommand, EditReviewCommand},
|
||||
delete_review,
|
||||
deps::{DeleteReviewDeps, EditReviewDeps, GetActivityFeedDeps},
|
||||
edit_review, get_activity_feed as get_feed_uc, get_diary, log_review,
|
||||
delete_review, edit_review, get_activity_feed as get_feed_uc, get_diary, log_review,
|
||||
queries::GetActivityFeedQuery,
|
||||
};
|
||||
|
||||
@@ -45,7 +43,8 @@ pub async fn get_diary(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<DiaryQueryParams>,
|
||||
) -> Result<Json<DiaryResponse>, ApiError> {
|
||||
let page = get_diary::execute(&state.app_ctx.repos.diary, to_diary_query(params)).await?;
|
||||
let page =
|
||||
get_diary::execute(&state.app_ctx.deps.diary.get_diary, to_diary_query(params)).await?;
|
||||
|
||||
Ok(Json(DiaryResponse {
|
||||
items: page
|
||||
@@ -103,13 +102,7 @@ pub async fn delete_review(
|
||||
review_id,
|
||||
requesting_user_id: user_id.value(),
|
||||
};
|
||||
let deps = DeleteReviewDeps {
|
||||
review: state.app_ctx.repos.review.clone(),
|
||||
diary: state.app_ctx.repos.diary.clone(),
|
||||
movie_command: state.app_ctx.repos.movie_command.clone(),
|
||||
event_publisher: state.app_ctx.services.event_publisher.clone(),
|
||||
};
|
||||
delete_review::execute(&deps, cmd).await?;
|
||||
delete_review::execute(&state.app_ctx.deps.diary.delete_review, cmd).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -145,11 +138,7 @@ pub async fn patch_review(
|
||||
watched_at,
|
||||
watch_medium: req.watch_medium,
|
||||
};
|
||||
let deps = EditReviewDeps {
|
||||
review: state.app_ctx.repos.review.clone(),
|
||||
event_publisher: state.app_ctx.services.event_publisher.clone(),
|
||||
};
|
||||
edit_review::execute(&deps, cmd).await?;
|
||||
edit_review::execute(&state.app_ctx.deps.diary.edit_review, cmd).await?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
@@ -180,13 +169,8 @@ pub async fn get_activity_feed(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ActivityFeedQueryParams>,
|
||||
) -> Result<Json<ActivityFeedResponse>, ApiError> {
|
||||
let deps = GetActivityFeedDeps {
|
||||
diary: state.app_ctx.repos.diary.clone(),
|
||||
social_query: state.app_ctx.repos.social_query_unified.clone(),
|
||||
config: state.app_ctx.config.clone(),
|
||||
};
|
||||
let page = get_feed_uc::execute(
|
||||
&deps,
|
||||
&state.app_ctx.deps.diary.get_activity_feed,
|
||||
GetActivityFeedQuery {
|
||||
limit: params.limit.unwrap_or(20),
|
||||
offset: params.offset.unwrap_or(0),
|
||||
@@ -274,13 +258,7 @@ pub async fn post_delete_review_html(
|
||||
review_id,
|
||||
requesting_user_id: user_id.value(),
|
||||
};
|
||||
let deps = DeleteReviewDeps {
|
||||
review: state.app_ctx.repos.review.clone(),
|
||||
diary: state.app_ctx.repos.diary.clone(),
|
||||
movie_command: state.app_ctx.repos.movie_command.clone(),
|
||||
event_publisher: state.app_ctx.services.event_publisher.clone(),
|
||||
};
|
||||
match delete_review::execute(&deps, cmd).await {
|
||||
match delete_review::execute(&state.app_ctx.deps.diary.delete_review, cmd).await {
|
||||
Ok(()) => {
|
||||
let redirect_url = form
|
||||
.redirect_after
|
||||
@@ -335,13 +313,12 @@ pub async fn get_activity_feed_html(
|
||||
filter_following,
|
||||
};
|
||||
|
||||
let deps = GetActivityFeedDeps {
|
||||
diary: state.app_ctx.repos.diary.clone(),
|
||||
social_query: state.app_ctx.repos.social_query_unified.clone(),
|
||||
config: state.app_ctx.config.clone(),
|
||||
};
|
||||
|
||||
match application::diary::get_activity_feed::execute(&deps, query).await {
|
||||
match application::diary::get_activity_feed::execute(
|
||||
&state.app_ctx.deps.diary.get_activity_feed,
|
||||
query,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(entries) => {
|
||||
let entry_limit = entries.limit;
|
||||
let entry_offset = entries.offset;
|
||||
|
||||
@@ -10,8 +10,6 @@ use api_types::{
|
||||
CreateGoalRequest, GoalDto, GoalsResponse, UpdateGoalRequest, UpdateUserSettingsRequest,
|
||||
UserSettingsDto,
|
||||
};
|
||||
use application::goals::deps::{GoalCommandDeps, GoalQueryDeps};
|
||||
|
||||
// ── Shared mapper ────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn goal_with_progress_to_dto(g: &domain::models::GoalWithProgress) -> GoalDto {
|
||||
@@ -39,12 +37,8 @@ pub async fn list_goals(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<GoalsResponse>, ApiError> {
|
||||
let deps = GoalQueryDeps {
|
||||
goal_query: state.app_ctx.repos.goal_query.clone(),
|
||||
stats: state.app_ctx.repos.stats.clone(),
|
||||
};
|
||||
let goals = application::goals::list::execute(
|
||||
&deps,
|
||||
&state.app_ctx.deps.goals.query,
|
||||
application::goals::queries::ListGoalsQuery {
|
||||
user_id: user.0.value(),
|
||||
},
|
||||
@@ -69,14 +63,8 @@ pub async fn create_goal(
|
||||
user: AuthenticatedUser,
|
||||
Json(req): Json<CreateGoalRequest>,
|
||||
) -> Result<Json<GoalDto>, ApiError> {
|
||||
let deps = GoalCommandDeps {
|
||||
goal_command: state.app_ctx.repos.goal_command.clone(),
|
||||
goal_query: state.app_ctx.repos.goal_query.clone(),
|
||||
stats: state.app_ctx.repos.stats.clone(),
|
||||
event_publisher: state.app_ctx.services.event_publisher.clone(),
|
||||
};
|
||||
let g = application::goals::create::execute(
|
||||
&deps,
|
||||
&state.app_ctx.deps.goals.command,
|
||||
application::goals::commands::CreateGoalCommand {
|
||||
user_id: user.0.value(),
|
||||
year: req.year,
|
||||
@@ -103,14 +91,8 @@ pub async fn update_goal(
|
||||
Path(year): Path<u16>,
|
||||
Json(req): Json<UpdateGoalRequest>,
|
||||
) -> Result<Json<GoalDto>, ApiError> {
|
||||
let deps = GoalCommandDeps {
|
||||
goal_command: state.app_ctx.repos.goal_command.clone(),
|
||||
goal_query: state.app_ctx.repos.goal_query.clone(),
|
||||
stats: state.app_ctx.repos.stats.clone(),
|
||||
event_publisher: state.app_ctx.services.event_publisher.clone(),
|
||||
};
|
||||
let g = application::goals::update::execute(
|
||||
&deps,
|
||||
&state.app_ctx.deps.goals.command,
|
||||
application::goals::commands::UpdateGoalCommand {
|
||||
user_id: user.0.value(),
|
||||
year,
|
||||
@@ -135,14 +117,8 @@ pub async fn delete_goal(
|
||||
user: AuthenticatedUser,
|
||||
Path(year): Path<u16>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let deps = GoalCommandDeps {
|
||||
goal_command: state.app_ctx.repos.goal_command.clone(),
|
||||
goal_query: state.app_ctx.repos.goal_query.clone(),
|
||||
stats: state.app_ctx.repos.stats.clone(),
|
||||
event_publisher: state.app_ctx.services.event_publisher.clone(),
|
||||
};
|
||||
application::goals::delete::execute(
|
||||
&deps,
|
||||
&state.app_ctx.deps.goals.command,
|
||||
application::goals::commands::DeleteGoalCommand {
|
||||
user_id: user.0.value(),
|
||||
year,
|
||||
@@ -165,12 +141,8 @@ pub async fn get_user_goals(
|
||||
AuthenticatedUser(_viewer): AuthenticatedUser,
|
||||
Path(user_id): Path<Uuid>,
|
||||
) -> Result<Json<GoalsResponse>, ApiError> {
|
||||
let deps = GoalQueryDeps {
|
||||
goal_query: state.app_ctx.repos.goal_query.clone(),
|
||||
stats: state.app_ctx.repos.stats.clone(),
|
||||
};
|
||||
let goals = application::goals::list::execute(
|
||||
&deps,
|
||||
&state.app_ctx.deps.goals.query,
|
||||
application::goals::queries::ListGoalsQuery { user_id },
|
||||
)
|
||||
.await?;
|
||||
@@ -194,7 +166,7 @@ pub async fn get_settings(
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<UserSettingsDto>, ApiError> {
|
||||
let settings = application::users::get_settings::execute(
|
||||
state.app_ctx.repos.user_settings.clone(),
|
||||
&state.app_ctx.deps.users.get_settings,
|
||||
user.0.value(),
|
||||
)
|
||||
.await?;
|
||||
@@ -220,7 +192,7 @@ pub async fn update_settings(
|
||||
Json(req): Json<UpdateUserSettingsRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
application::users::update_settings::execute(
|
||||
state.app_ctx.repos.user_settings.clone(),
|
||||
&state.app_ctx.deps.users.update_settings,
|
||||
application::users::update_settings::UpdateUserSettingsCommand {
|
||||
user_id: user.0.value(),
|
||||
federate_goals: req.federate_goals,
|
||||
|
||||
@@ -29,11 +29,7 @@ pub(crate) fn build_export_response(
|
||||
ExportFormat::Json => ("application/json", "diary.json"),
|
||||
};
|
||||
let query = ExportQuery { user_id, format };
|
||||
let stream = export_diary_uc::execute(
|
||||
&state.app_ctx.repos.diary,
|
||||
&state.app_ctx.services.diary_exporter,
|
||||
query,
|
||||
);
|
||||
let stream = export_diary_uc::execute(&state.app_ctx.deps.diary.export_diary, query);
|
||||
let stream = stream.map(|r| {
|
||||
if let Err(ref e) = r {
|
||||
tracing::error!("diary export stream error: {e}");
|
||||
@@ -146,16 +142,25 @@ pub(crate) async fn build_page_context(
|
||||
csrf_token: String,
|
||||
) -> HtmlPageContext {
|
||||
let uuid = user_id.as_ref().map(|u| u.value());
|
||||
let (user_email, is_admin) = if let Some(ref id) = user_id {
|
||||
let user = state.app_ctx.repos.user.find_by_id(id).await.ok().flatten();
|
||||
let email = user.as_ref().map(|u| u.email().value().to_string());
|
||||
let admin = user
|
||||
.as_ref()
|
||||
.map(|u| matches!(u.role(), domain::models::UserRole::Admin))
|
||||
.unwrap_or(false);
|
||||
(email, admin)
|
||||
// Page chrome (email/admin badge, pending-follow badge) degrades rather than
|
||||
// blanking the page: a failed lookup or count logs a warning and falls back
|
||||
// to a zeroed `PageViewer`, same as the previous silent `.unwrap_or(0)` — now
|
||||
// logged and explicit, per the error policy in `get_page_viewer`'s doc comment.
|
||||
let (user_email, is_admin, pending_follow_count) = if let Some(ref id) = user_id {
|
||||
match application::users::get_page_viewer::execute(
|
||||
&state.app_ctx.deps.users.get_page_viewer,
|
||||
id.value(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(viewer) => (viewer.email, viewer.is_admin, viewer.pending_follow_count),
|
||||
Err(e) => {
|
||||
tracing::warn!("get_page_viewer failed for {}: {e}", id.value());
|
||||
(None, false, 0)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(None, false)
|
||||
(None, false, 0)
|
||||
};
|
||||
HtmlPageContext {
|
||||
user_email,
|
||||
@@ -167,5 +172,6 @@ pub(crate) async fn build_page_context(
|
||||
canonical_url: state.app_ctx.config.base_url.clone(),
|
||||
csrf_token,
|
||||
page_rss_url: None,
|
||||
pending_follow_count,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,17 +13,14 @@ use std::collections::HashMap;
|
||||
|
||||
use crate::render::render_page;
|
||||
use application::import::{
|
||||
apply_mapping as apply_import_mapping, apply_profile as apply_import_profile,
|
||||
apply_mapping as apply_import_mapping, apply_profile_and_map,
|
||||
commands::{
|
||||
ApplyImportMappingCommand, ApplyImportProfileCommand, CreateImportSessionCommand,
|
||||
ApplyImportMappingCommand, ApplyProfileAndMapCommand, CreateImportSessionCommand,
|
||||
DeleteImportProfileCommand, ExecuteImportCommand, SaveImportProfileCommand,
|
||||
},
|
||||
create_session as create_import_session, delete_profile as delete_import_profile,
|
||||
deps::{
|
||||
ApplyMappingDeps, ApplyProfileDeps, CreateSessionDeps, ExecuteImportDeps, SaveProfileDeps,
|
||||
},
|
||||
execute as execute_import, list_profiles as list_import_profiles,
|
||||
save_profile as save_import_profile,
|
||||
execute as execute_import, get_mapping_stage, get_preview_stage, get_session_state,
|
||||
list_profiles as list_import_profiles, save_profile as save_import_profile,
|
||||
};
|
||||
use domain::errors::DomainError;
|
||||
use domain::models::{
|
||||
@@ -111,7 +108,7 @@ pub async fn get_import_page(
|
||||
) -> impl IntoResponse {
|
||||
let ctx = super::helpers::build_page_context(&state, Some(user_id.clone()), csrf.0).await;
|
||||
let profiles =
|
||||
list_import_profiles::execute(state.app_ctx.repos.import_profile.clone(), &user_id)
|
||||
list_import_profiles::execute(&state.app_ctx.deps.import.list_profiles, &user_id)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
@@ -163,10 +160,7 @@ pub async fn post_upload(
|
||||
};
|
||||
|
||||
match create_import_session::execute(
|
||||
&CreateSessionDeps {
|
||||
import_session: state.app_ctx.repos.import_session.clone(),
|
||||
document_parser: state.app_ctx.services.document_parser.clone(),
|
||||
},
|
||||
&state.app_ctx.deps.import.create_session,
|
||||
CreateImportSessionCommand {
|
||||
user_id: user_id.value(),
|
||||
bytes,
|
||||
@@ -194,21 +188,17 @@ pub async fn get_mapping_page(
|
||||
else {
|
||||
return Redirect::to("/import").into_response();
|
||||
};
|
||||
let Ok(Some(session)) = state
|
||||
.app_ctx
|
||||
.repos
|
||||
.import_session
|
||||
.get(&session_id, &user_id)
|
||||
.await
|
||||
let Ok(stage) = get_mapping_stage::execute(
|
||||
&state.app_ctx.deps.import.get_mapping_stage,
|
||||
session_id,
|
||||
user_id.value(),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return Redirect::to("/import").into_response();
|
||||
};
|
||||
let Some(parsed) = session.parsed_file else {
|
||||
return Redirect::to("/import").into_response();
|
||||
};
|
||||
|
||||
let ctx = super::helpers::build_page_context(&state, Some(user_id), csrf.0).await;
|
||||
let sample_rows: Vec<Vec<String>> = parsed.rows.into_iter().take(5).collect();
|
||||
let domain_fields: Vec<(&str, &str)> = vec![
|
||||
("title", "Title"),
|
||||
("release_year", "Release Year"),
|
||||
@@ -221,8 +211,8 @@ pub async fn get_mapping_page(
|
||||
render_page(ImportMappingTemplate {
|
||||
ctx: &ctx,
|
||||
session_id: &session_id_str,
|
||||
columns: &parsed.columns,
|
||||
sample_rows: &sample_rows,
|
||||
columns: &stage.columns,
|
||||
sample_rows: &stage.sample_rows,
|
||||
domain_fields: &domain_fields,
|
||||
error: None,
|
||||
})
|
||||
@@ -255,11 +245,7 @@ pub async fn post_mapping(
|
||||
.into_response();
|
||||
}
|
||||
match apply_import_mapping::execute(
|
||||
&ApplyMappingDeps {
|
||||
import_session: state.app_ctx.repos.import_session.clone(),
|
||||
document_parser: state.app_ctx.services.document_parser.clone(),
|
||||
movie_query: state.app_ctx.repos.movie_query.clone(),
|
||||
},
|
||||
&state.app_ctx.deps.import.apply_mapping,
|
||||
ApplyImportMappingCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id: session_id.value(),
|
||||
@@ -290,24 +276,25 @@ pub async fn get_preview_page(
|
||||
else {
|
||||
return Redirect::to("/import").into_response();
|
||||
};
|
||||
let Ok(Some(session)) = state
|
||||
.app_ctx
|
||||
.repos
|
||||
.import_session
|
||||
.get(&session_id, &user_id)
|
||||
.await
|
||||
let Ok(stage) = get_preview_stage::execute(
|
||||
&state.app_ctx.deps.import.get_preview_stage,
|
||||
session_id,
|
||||
user_id.value(),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return Redirect::to("/import").into_response();
|
||||
};
|
||||
|
||||
if session.row_results.is_none() {
|
||||
return Redirect::to(&format!("/import/{}/mapping", session_id_str)).into_response();
|
||||
}
|
||||
let preview = match stage {
|
||||
get_preview_stage::PreviewStage::Ready(preview) => preview,
|
||||
get_preview_stage::PreviewStage::NotYetMapped => {
|
||||
return Redirect::to(&format!("/import/{}/mapping", session_id_str)).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let parsed = session.parsed_file.unwrap_or_default();
|
||||
let annotated: Vec<AnnotatedRow> = session.row_results.unwrap_or_default();
|
||||
|
||||
let rows: Vec<ImportPreviewRow> = annotated
|
||||
let rows: Vec<ImportPreviewRow> = preview
|
||||
.rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, a)| annotated_to_preview_row(i, a))
|
||||
@@ -317,7 +304,7 @@ pub async fn get_preview_page(
|
||||
render_page(ImportPreviewTemplate {
|
||||
ctx: &ctx,
|
||||
session_id: &session_id_str,
|
||||
columns: &parsed.columns,
|
||||
columns: &preview.columns,
|
||||
rows: &rows,
|
||||
})
|
||||
.into_response()
|
||||
@@ -353,10 +340,7 @@ pub async fn post_confirm(
|
||||
.filter(|n| !n.trim().is_empty());
|
||||
if let Some(name) = profile_name {
|
||||
let _ = save_import_profile::execute(
|
||||
&SaveProfileDeps {
|
||||
import_session: state.app_ctx.repos.import_session.clone(),
|
||||
import_profile: state.app_ctx.repos.import_profile.clone(),
|
||||
},
|
||||
&state.app_ctx.deps.import.save_profile,
|
||||
SaveImportProfileCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id: session_id.value(),
|
||||
@@ -374,10 +358,7 @@ pub async fn post_confirm(
|
||||
.collect();
|
||||
|
||||
match execute_import::execute(
|
||||
&ExecuteImportDeps {
|
||||
import_session: state.app_ctx.repos.import_session.clone(),
|
||||
review_logger: state.app_ctx.services.review_logger.clone(),
|
||||
},
|
||||
&state.app_ctx.deps.import.execute_import,
|
||||
ExecuteImportCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id: session_id.value(),
|
||||
@@ -412,7 +393,7 @@ pub async fn post_delete_profile(
|
||||
}
|
||||
if let Ok(profile_id) = profile_id_str.parse::<uuid::Uuid>() {
|
||||
let _ = delete_import_profile::execute(
|
||||
state.app_ctx.repos.import_profile.clone(),
|
||||
&state.app_ctx.deps.import.delete_profile,
|
||||
DeleteImportProfileCommand {
|
||||
user_id: user_id.value(),
|
||||
profile_id,
|
||||
@@ -498,10 +479,7 @@ pub async fn api_post_session(
|
||||
_ => FileFormat::Csv,
|
||||
};
|
||||
let r = create_import_session::execute(
|
||||
&CreateSessionDeps {
|
||||
import_session: state.app_ctx.repos.import_session.clone(),
|
||||
document_parser: state.app_ctx.services.document_parser.clone(),
|
||||
},
|
||||
&state.app_ctx.deps.import.create_session,
|
||||
CreateImportSessionCommand {
|
||||
user_id: user_id.value(),
|
||||
bytes,
|
||||
@@ -535,20 +513,17 @@ pub async fn api_get_session(
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
|
||||
let session = state
|
||||
.app_ctx
|
||||
.repos
|
||||
.import_session
|
||||
.get(&session_id, &user_id)
|
||||
.await?
|
||||
.ok_or(DomainError::NotFound("session not found".into()))?;
|
||||
let parsed = session.parsed_file.unwrap_or_default();
|
||||
let row_count = parsed.rows.len();
|
||||
let state_data = get_session_state::execute(
|
||||
&state.app_ctx.deps.import.get_session_state,
|
||||
session_id,
|
||||
user_id.value(),
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::Json(SessionStateResponse {
|
||||
session_id: session_id_str,
|
||||
columns: parsed.columns,
|
||||
has_mappings: session.field_mappings.is_some(),
|
||||
row_count,
|
||||
columns: state_data.columns,
|
||||
has_mappings: state_data.has_mappings,
|
||||
row_count: state_data.row_count,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -596,11 +571,7 @@ pub async fn api_put_mapping(
|
||||
.collect();
|
||||
|
||||
let rows = apply_import_mapping::execute(
|
||||
&ApplyMappingDeps {
|
||||
import_session: state.app_ctx.repos.import_session.clone(),
|
||||
document_parser: state.app_ctx.services.document_parser.clone(),
|
||||
movie_query: state.app_ctx.repos.movie_query.clone(),
|
||||
},
|
||||
&state.app_ctx.deps.import.apply_mapping,
|
||||
ApplyImportMappingCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id: session_id.value(),
|
||||
@@ -621,15 +592,21 @@ pub async fn api_get_preview(
|
||||
.map(ImportSessionId::from_uuid)
|
||||
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
|
||||
|
||||
let session = state
|
||||
.app_ctx
|
||||
.repos
|
||||
.import_session
|
||||
.get(&session_id, &user_id)
|
||||
.await?
|
||||
.ok_or(DomainError::NotFound("session not found".into()))?;
|
||||
let stage = get_preview_stage::execute(
|
||||
&state.app_ctx.deps.import.get_preview_stage,
|
||||
session_id,
|
||||
user_id.value(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let annotated: Vec<AnnotatedRow> = session.row_results.unwrap_or_default();
|
||||
// Not-yet-mapped sessions render as an empty preview here (200, `rows: []`),
|
||||
// matching this endpoint's behavior before the stage-gate moved into
|
||||
// `get_preview_stage` — unlike the HTML handler, this API route never
|
||||
// redirected on the same condition, so its response shape is unchanged.
|
||||
let annotated: Vec<AnnotatedRow> = match stage {
|
||||
get_preview_stage::PreviewStage::Ready(preview) => preview.rows,
|
||||
get_preview_stage::PreviewStage::NotYetMapped => Vec::new(),
|
||||
};
|
||||
let rows = annotated
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -689,10 +666,7 @@ pub async fn api_post_confirm(
|
||||
.map(ImportSessionId::from_uuid)
|
||||
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
|
||||
let s = execute_import::execute(
|
||||
&ExecuteImportDeps {
|
||||
import_session: state.app_ctx.repos.import_session.clone(),
|
||||
review_logger: state.app_ctx.services.review_logger.clone(),
|
||||
},
|
||||
&state.app_ctx.deps.import.execute_import,
|
||||
ExecuteImportCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id: session_id.value(),
|
||||
@@ -720,7 +694,7 @@ pub async fn api_get_profiles(
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let profiles =
|
||||
list_import_profiles::execute(state.app_ctx.repos.import_profile.clone(), &user_id).await?;
|
||||
list_import_profiles::execute(&state.app_ctx.deps.import.list_profiles, &user_id).await?;
|
||||
Ok(axum::Json(
|
||||
profiles
|
||||
.into_iter()
|
||||
@@ -756,10 +730,7 @@ pub async fn api_post_profile(
|
||||
.map(ImportSessionId::from_uuid)
|
||||
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
|
||||
let id = save_import_profile::execute(
|
||||
&SaveProfileDeps {
|
||||
import_session: state.app_ctx.repos.import_session.clone(),
|
||||
import_profile: state.app_ctx.repos.import_profile.clone(),
|
||||
},
|
||||
&state.app_ctx.deps.import.save_profile,
|
||||
SaveImportProfileCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id: session_id.value(),
|
||||
@@ -791,7 +762,7 @@ pub async fn api_delete_profile(
|
||||
.parse::<uuid::Uuid>()
|
||||
.map_err(|_| DomainError::ValidationError("invalid profile id".into()))?;
|
||||
delete_import_profile::execute(
|
||||
state.app_ctx.repos.import_profile.clone(),
|
||||
&state.app_ctx.deps.import.delete_profile,
|
||||
DeleteImportProfileCommand {
|
||||
user_id: user_id.value(),
|
||||
profile_id,
|
||||
@@ -828,42 +799,14 @@ pub async fn api_apply_profile(
|
||||
.parse::<uuid::Uuid>()
|
||||
.map_err(|_| DomainError::ValidationError("invalid profile id".into()))?;
|
||||
|
||||
apply_import_profile::execute(
|
||||
&ApplyProfileDeps {
|
||||
import_profile: state.app_ctx.repos.import_profile.clone(),
|
||||
import_session: state.app_ctx.repos.import_session.clone(),
|
||||
},
|
||||
ApplyImportProfileCommand {
|
||||
let rows = apply_profile_and_map::execute(
|
||||
&state.app_ctx.deps.import.apply_profile_and_map,
|
||||
ApplyProfileAndMapCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id,
|
||||
profile_id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let session = state
|
||||
.app_ctx
|
||||
.repos
|
||||
.import_session
|
||||
.get(&ImportSessionId::from_uuid(session_id), &user_id)
|
||||
.await?
|
||||
.ok_or(DomainError::NotFound(
|
||||
"session not found after profile apply".into(),
|
||||
))?;
|
||||
|
||||
let mappings = session.field_mappings.unwrap_or_default();
|
||||
let rows = apply_import_mapping::execute(
|
||||
&ApplyMappingDeps {
|
||||
import_session: state.app_ctx.repos.import_session.clone(),
|
||||
document_parser: state.app_ctx.services.document_parser.clone(),
|
||||
movie_query: state.app_ctx.repos.movie_query.clone(),
|
||||
},
|
||||
ApplyImportMappingCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id,
|
||||
mappings,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::Json(serde_json::json!({"row_count": rows.len()})))
|
||||
}
|
||||
|
||||
@@ -41,9 +41,10 @@ pub async fn get_integrations_page(
|
||||
let query = GetWebhookTokensQuery {
|
||||
user_id: user_id.value(),
|
||||
};
|
||||
let tokens = get_webhook_tokens::execute(state.app_ctx.repos.webhook_token.clone(), query)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let tokens =
|
||||
get_webhook_tokens::execute(&state.app_ctx.deps.integrations.get_webhook_tokens, query)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let token_views: Vec<template_askama::WebhookTokenView> = tokens
|
||||
.iter()
|
||||
@@ -81,7 +82,12 @@ pub async fn post_generate_token(
|
||||
label: form.label.filter(|l| !l.trim().is_empty()),
|
||||
};
|
||||
|
||||
match generate_webhook_token::execute(state.app_ctx.repos.webhook_token.clone(), cmd).await {
|
||||
match generate_webhook_token::execute(
|
||||
&state.app_ctx.deps.integrations.generate_webhook_token,
|
||||
cmd,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
let encoded = percent_encoding::utf8_percent_encode(
|
||||
&result.token_plaintext,
|
||||
@@ -112,7 +118,8 @@ pub async fn post_revoke_token(
|
||||
token_id,
|
||||
};
|
||||
if let Err(e) =
|
||||
revoke_webhook_token::execute(state.app_ctx.repos.webhook_token.clone(), cmd).await
|
||||
revoke_webhook_token::execute(&state.app_ctx.deps.integrations.revoke_webhook_token, cmd)
|
||||
.await
|
||||
{
|
||||
tracing::error!("revoke token failed: {:?}", e);
|
||||
}
|
||||
@@ -135,7 +142,7 @@ pub async fn get_watch_queue_page(
|
||||
let query = GetWatchQueueQuery {
|
||||
user_id: user_id.value(),
|
||||
};
|
||||
let events = get_watch_queue::execute(state.app_ctx.repos.watch_event_query.clone(), query)
|
||||
let events = get_watch_queue::execute(&state.app_ctx.deps.integrations.get_watch_queue, query)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
@@ -172,13 +179,8 @@ pub async fn post_confirm_single(
|
||||
}],
|
||||
};
|
||||
|
||||
match confirm_watch_events::execute(
|
||||
state.app_ctx.repos.watch_event_command.clone(),
|
||||
state.app_ctx.repos.watch_event_query.clone(),
|
||||
state.app_ctx.services.review_logger.clone(),
|
||||
cmd,
|
||||
)
|
||||
.await
|
||||
match confirm_watch_events::execute(&state.app_ctx.deps.integrations.confirm_watch_events, cmd)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Redirect::to("/watch-queue").into_response(),
|
||||
Err(e) => {
|
||||
@@ -204,12 +206,8 @@ pub async fn post_dismiss_single(
|
||||
event_ids: vec![event_id],
|
||||
};
|
||||
|
||||
match dismiss_watch_events::execute(
|
||||
state.app_ctx.repos.watch_event_command.clone(),
|
||||
state.app_ctx.repos.watch_event_query.clone(),
|
||||
cmd,
|
||||
)
|
||||
.await
|
||||
match dismiss_watch_events::execute(&state.app_ctx.deps.integrations.dismiss_watch_events, cmd)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Redirect::to("/watch-queue").into_response(),
|
||||
Err(e) => {
|
||||
|
||||
@@ -8,7 +8,6 @@ pub mod integrations;
|
||||
pub mod movies;
|
||||
pub mod rss;
|
||||
pub mod search;
|
||||
#[cfg(feature = "federation")]
|
||||
pub mod social;
|
||||
pub mod users;
|
||||
pub mod watchlist;
|
||||
|
||||
@@ -9,11 +9,10 @@ use uuid::Uuid;
|
||||
use application::{
|
||||
diary::{
|
||||
commands::SyncPosterCommand,
|
||||
deps::GetMovieSocialPageDeps,
|
||||
get_movie_social_page, get_review_history,
|
||||
queries::{GetMovieSocialPageQuery, GetReviewHistoryQuery},
|
||||
},
|
||||
movies::{deps::SyncPosterDeps, get_movies, queries::GetMoviesQuery, sync_poster},
|
||||
movies::{get_movies, queries::GetMoviesQuery, sync_poster},
|
||||
watchlist::{is_on as is_on_watchlist, queries::IsOnWatchlistQuery},
|
||||
};
|
||||
use domain::services::review_history::Trend;
|
||||
@@ -48,7 +47,7 @@ pub async fn list_movies(
|
||||
Query(params): Query<MoviesQueryParams>,
|
||||
) -> Result<Json<MoviesResponse>, ApiError> {
|
||||
let page = get_movies::execute(
|
||||
state.app_ctx.repos.movie_query.clone(),
|
||||
&state.app_ctx.deps.movies.get_movies,
|
||||
GetMoviesQuery {
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
@@ -84,7 +83,7 @@ pub async fn get_review_history(
|
||||
Path(movie_id): Path<Uuid>,
|
||||
) -> Result<Json<ReviewHistoryResponse>, ApiError> {
|
||||
let (history, trend) = get_review_history::execute(
|
||||
&state.app_ctx.repos.diary,
|
||||
&state.app_ctx.deps.diary.get_review_history,
|
||||
GetReviewHistoryQuery { movie_id },
|
||||
)
|
||||
.await?;
|
||||
@@ -121,16 +120,7 @@ pub async fn sync_poster(
|
||||
Path(movie_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
sync_poster::execute(
|
||||
&SyncPosterDeps {
|
||||
movie_command: state.app_ctx.repos.movie_command.clone(),
|
||||
movie_query: state.app_ctx.repos.movie_query.clone(),
|
||||
movie_profile: state.app_ctx.repos.movie_profile.clone(),
|
||||
metadata: state.app_ctx.services.metadata.clone(),
|
||||
poster_fetcher: state.app_ctx.services.poster_fetcher.clone(),
|
||||
object_storage: state.app_ctx.services.object_storage.clone(),
|
||||
event_publisher: state.app_ctx.services.event_publisher.clone(),
|
||||
search_command: state.app_ctx.repos.search_command.clone(),
|
||||
},
|
||||
&state.app_ctx.deps.movies.sync_poster,
|
||||
SyncPosterCommand { movie_id },
|
||||
)
|
||||
.await?;
|
||||
@@ -154,11 +144,7 @@ pub async fn get_movie_detail(
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
|
||||
let result = get_movie_social_page::execute(
|
||||
&GetMovieSocialPageDeps {
|
||||
movie_query: state.app_ctx.repos.movie_query.clone(),
|
||||
diary: state.app_ctx.repos.diary.clone(),
|
||||
movie_profile: state.app_ctx.repos.movie_profile.clone(),
|
||||
},
|
||||
&state.app_ctx.deps.diary.get_movie_social_page,
|
||||
GetMovieSocialPageQuery {
|
||||
movie_id,
|
||||
limit,
|
||||
@@ -210,7 +196,7 @@ pub async fn get_movie_profile(
|
||||
) -> impl IntoResponse {
|
||||
use application::movies::get_movie_profile;
|
||||
let query = get_movie_profile::GetMovieProfileQuery { movie_id };
|
||||
match get_movie_profile::execute(state.app_ctx.repos.movie_profile.clone(), query).await {
|
||||
match get_movie_profile::execute(&state.app_ctx.deps.movies.get_movie_profile, query).await {
|
||||
Ok(Some(result)) => {
|
||||
let p = result.profile;
|
||||
Json(MovieProfileResponse {
|
||||
@@ -288,11 +274,7 @@ pub async fn get_movie_detail_html(
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
|
||||
match get_movie_social_page::execute(
|
||||
&GetMovieSocialPageDeps {
|
||||
movie_query: state.app_ctx.repos.movie_query.clone(),
|
||||
diary: state.app_ctx.repos.diary.clone(),
|
||||
movie_profile: state.app_ctx.repos.movie_profile.clone(),
|
||||
},
|
||||
&state.app_ctx.deps.diary.get_movie_social_page,
|
||||
GetMovieSocialPageQuery {
|
||||
movie_id,
|
||||
limit,
|
||||
@@ -314,7 +296,7 @@ pub async fn get_movie_detail_html(
|
||||
result.reviews.offset + result.reviews.limit < result.reviews.total_count as u32;
|
||||
let on_watchlist = match &user_id {
|
||||
Some(uid) => is_on_watchlist::execute(
|
||||
state.app_ctx.repos.watchlist.clone(),
|
||||
&state.app_ctx.deps.watchlist.is_on_watchlist,
|
||||
IsOnWatchlistQuery {
|
||||
user_id: uid.value(),
|
||||
movie_id,
|
||||
|
||||
@@ -5,8 +5,8 @@ use axum::{
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{diary::get_diary, diary::queries::GetDiaryQuery};
|
||||
use domain::{errors::DomainError, models::ReviewSortBy, value_objects::UserId};
|
||||
use application::{diary::get_diary, diary::get_user_feed, diary::queries::GetDiaryQuery};
|
||||
use domain::{errors::DomainError, models::ReviewSortBy};
|
||||
|
||||
use crate::{errors::ApiError, state::AppState};
|
||||
|
||||
@@ -18,7 +18,7 @@ pub async fn get_feed(State(state): State<AppState>) -> Result<impl IntoResponse
|
||||
movie_id: None,
|
||||
user_id: None,
|
||||
};
|
||||
let page = get_diary::execute(&state.app_ctx.repos.diary, query).await?;
|
||||
let page = get_diary::execute(&state.app_ctx.deps.diary.get_diary, query).await?;
|
||||
let xml = state
|
||||
.rss_renderer
|
||||
.render_feed(&page.items, "Movie Diary")
|
||||
@@ -33,30 +33,18 @@ pub async fn get_user_feed(
|
||||
State(state): State<AppState>,
|
||||
Path(user_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let user = state
|
||||
.app_ctx
|
||||
.repos
|
||||
.user
|
||||
.find_by_id(&UserId::from_uuid(user_id))
|
||||
.await
|
||||
.map_err(ApiError)?
|
||||
.ok_or_else(|| ApiError(DomainError::NotFound(format!("User {user_id}"))))?;
|
||||
let feed = get_user_feed::execute(
|
||||
&state.app_ctx.deps.diary.get_user_feed,
|
||||
user_id,
|
||||
super::RSS_FEED_LIMIT,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let query = GetDiaryQuery {
|
||||
limit: Some(super::RSS_FEED_LIMIT),
|
||||
offset: Some(0),
|
||||
sort_by: Some(ReviewSortBy::Descending),
|
||||
movie_id: None,
|
||||
user_id: Some(user_id),
|
||||
};
|
||||
let page = get_diary::execute(&state.app_ctx.repos.diary, query).await?;
|
||||
|
||||
let display_name = user.email().value().split('@').next().unwrap_or("User");
|
||||
let title = format!("{}'s Movie Diary", display_name);
|
||||
let title = format!("{}'s Movie Diary", feed.author.display_name);
|
||||
|
||||
let xml = state
|
||||
.rss_renderer
|
||||
.render_feed(&page.items, &title)
|
||||
.render_feed(&feed.entries, &title)
|
||||
.map_err(|e| ApiError(DomainError::InfrastructureError(e)))?;
|
||||
|
||||
Ok((
|
||||
|
||||
@@ -5,7 +5,7 @@ use axum::{
|
||||
};
|
||||
|
||||
use application::{
|
||||
person::{deps::GetPersonDeps, get as get_person, get_credits as get_person_credits},
|
||||
person::{get as get_person, get_credits as get_person_credits},
|
||||
search::execute as search_uc,
|
||||
};
|
||||
use domain::models::{PersonId, collections::PageParams};
|
||||
@@ -45,7 +45,7 @@ pub async fn get_search(
|
||||
},
|
||||
};
|
||||
|
||||
match search_uc::execute(state.app_ctx.repos.search_port.clone(), query).await {
|
||||
match search_uc::execute(&state.app_ctx.deps.search.execute, query).await {
|
||||
Ok(results) => axum::Json(SearchResponse {
|
||||
movies: PaginatedMovieHits {
|
||||
items: results
|
||||
@@ -101,11 +101,12 @@ pub async fn get_person_handler(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> impl IntoResponse {
|
||||
let deps = GetPersonDeps {
|
||||
person_query: state.app_ctx.repos.person_query.clone(),
|
||||
event_publisher: state.app_ctx.services.event_publisher.clone(),
|
||||
};
|
||||
match get_person::execute(&deps, PersonId::from_uuid(id)).await {
|
||||
match get_person::execute(
|
||||
&state.app_ctx.deps.person.get_person,
|
||||
PersonId::from_uuid(id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(person)) => {
|
||||
axum::Json(crate::mappers::search::person_to_dto(&person)).into_response()
|
||||
}
|
||||
@@ -127,11 +128,12 @@ pub async fn get_person_credits_handler(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> impl IntoResponse {
|
||||
let deps = GetPersonDeps {
|
||||
person_query: state.app_ctx.repos.person_query.clone(),
|
||||
event_publisher: state.app_ctx.services.event_publisher.clone(),
|
||||
};
|
||||
match get_person_credits::execute(&deps, PersonId::from_uuid(id)).await {
|
||||
match get_person_credits::execute(
|
||||
&state.app_ctx.deps.person.get_person,
|
||||
PersonId::from_uuid(id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(credits) => axum::Json(PersonCreditsDto {
|
||||
person: crate::mappers::search::person_to_dto(&credits.person),
|
||||
cast: credits
|
||||
|
||||
@@ -9,77 +9,58 @@ use uuid::Uuid;
|
||||
use crate::{
|
||||
csrf::CsrfToken,
|
||||
errors::ApiError,
|
||||
extractors::{AdminApiUser, AuthenticatedUser, RequiredCookieUser},
|
||||
forms::{
|
||||
ActorUrlForm, BlockDomainForm, FollowForm, FollowerActionForm, RemoveDomainForm,
|
||||
UnfollowForm,
|
||||
},
|
||||
extractors::{AuthenticatedUser, RequiredCookieUser},
|
||||
forms::{FollowForm, FollowerActionForm, UnfollowForm},
|
||||
render::render_page,
|
||||
state::AppState,
|
||||
};
|
||||
#[cfg(feature = "federation")]
|
||||
use crate::{
|
||||
extractors::AdminApiUser,
|
||||
forms::{ActorUrlForm, BlockDomainForm, RemoveDomainForm},
|
||||
};
|
||||
use api_types::{
|
||||
ActorListResponse, ActorUrlRequest, AddBlockedDomainRequest, BlockedActorResponse,
|
||||
BlockedDomainResponse, FollowRequest, RemoteActorDto,
|
||||
};
|
||||
use application::social::deps::{SocialCommandDeps, SocialQueryDeps};
|
||||
use domain::value_objects::{FollowTarget, SocialActor, SocialIdentity};
|
||||
use template_askama::{
|
||||
BlockedActorsTemplate, BlockedDomainsTemplate, FollowersTemplate, FollowingTemplate,
|
||||
RemoteActorData,
|
||||
ActorListResponse, ActorUrlRequest, FollowRelationResponse, FollowRequest,
|
||||
PendingCountResponse, RemoteActorDto,
|
||||
};
|
||||
#[cfg(feature = "federation")]
|
||||
use api_types::{AddBlockedDomainRequest, BlockedActorResponse, BlockedDomainResponse};
|
||||
use domain::value_objects::{FollowTarget, InstanceIdentity, SocialActor, SocialIdentity};
|
||||
#[cfg(feature = "federation")]
|
||||
use template_askama::{BlockedActorsTemplate, BlockedDomainsTemplate};
|
||||
use template_askama::{FollowersTemplate, FollowingTemplate, RemoteActorData};
|
||||
|
||||
use super::helpers::{build_page_context, encode_error};
|
||||
|
||||
impl From<&AppState> for SocialCommandDeps {
|
||||
fn from(state: &AppState) -> Self {
|
||||
Self {
|
||||
social_command: state.app_ctx.repos.social_command.clone(),
|
||||
social_query: state.app_ctx.repos.social_query_unified.clone(),
|
||||
event_publisher: state.app_ctx.services.event_publisher.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&AppState> for SocialQueryDeps {
|
||||
fn from(state: &AppState) -> Self {
|
||||
Self {
|
||||
social_query: state.app_ctx.repos.social_query_unified.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ap_to_domain(e: anyhow::Error) -> domain::errors::DomainError {
|
||||
tracing::error!("ActivityPub error: {:?}", e);
|
||||
domain::errors::DomainError::InfrastructureError(e.to_string())
|
||||
}
|
||||
|
||||
fn actor_url(identity: &SocialIdentity) -> String {
|
||||
match identity {
|
||||
SocialIdentity::Remote { actor_url } => actor_url.clone(),
|
||||
SocialIdentity::Local(uid) => format!("local:{}", uid.value()),
|
||||
}
|
||||
}
|
||||
|
||||
fn social_actor_to_dto(actor: SocialActor) -> RemoteActorDto {
|
||||
fn social_actor_to_dto(actor: SocialActor, instance: &InstanceIdentity) -> RemoteActorDto {
|
||||
RemoteActorDto {
|
||||
url: actor_url(&actor.identity),
|
||||
handle: actor.handle,
|
||||
display_name: actor.display_name,
|
||||
}
|
||||
}
|
||||
|
||||
fn social_actor_to_blocked_dto(actor: SocialActor) -> BlockedActorResponse {
|
||||
BlockedActorResponse {
|
||||
url: actor_url(&actor.identity),
|
||||
url: instance.actor_url_of(&actor.identity),
|
||||
user_id: match &actor.identity {
|
||||
SocialIdentity::Local(uid) => Some(uid.value()),
|
||||
SocialIdentity::Remote { .. } => None,
|
||||
},
|
||||
handle: actor.handle,
|
||||
display_name: actor.display_name,
|
||||
avatar_url: actor.avatar_url,
|
||||
}
|
||||
}
|
||||
|
||||
fn social_actor_to_template(actor: SocialActor) -> RemoteActorData {
|
||||
#[cfg(feature = "federation")]
|
||||
fn social_actor_to_blocked_dto(
|
||||
actor: SocialActor,
|
||||
instance: &InstanceIdentity,
|
||||
) -> BlockedActorResponse {
|
||||
BlockedActorResponse {
|
||||
url: instance.actor_url_of(&actor.identity),
|
||||
handle: actor.handle,
|
||||
display_name: actor.display_name,
|
||||
avatar_url: actor.avatar_url,
|
||||
}
|
||||
}
|
||||
|
||||
fn social_actor_to_template(actor: SocialActor, instance: &InstanceIdentity) -> RemoteActorData {
|
||||
RemoteActorData {
|
||||
url: actor_url(&actor.identity),
|
||||
url: instance.actor_url_of(&actor.identity),
|
||||
handle: actor.handle,
|
||||
display_name: actor.display_name,
|
||||
avatar_url: actor.avatar_url,
|
||||
@@ -88,6 +69,7 @@ fn social_actor_to_template(actor: SocialActor) -> RemoteActorData {
|
||||
|
||||
// ── API ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/admin/blocked-domains",
|
||||
responses(
|
||||
@@ -101,13 +83,7 @@ pub async fn get_blocked_domains_admin(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminApiUser,
|
||||
) -> Result<Json<Vec<BlockedDomainResponse>>, ApiError> {
|
||||
let domains = state
|
||||
.app_ctx
|
||||
.services
|
||||
.ap_service
|
||||
.get_blocked_domains()
|
||||
.await
|
||||
.map_err(ap_to_domain)?;
|
||||
let domains = state.app_ctx.ap_blocklist.get_blocked_domains().await?;
|
||||
Ok(Json(
|
||||
domains
|
||||
.into_iter()
|
||||
@@ -120,6 +96,7 @@ pub async fn get_blocked_domains_admin(
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/admin/blocked-domains",
|
||||
request_body = AddBlockedDomainRequest,
|
||||
@@ -137,14 +114,13 @@ pub async fn add_blocked_domain_admin(
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
state
|
||||
.app_ctx
|
||||
.services
|
||||
.ap_service
|
||||
.ap_blocklist
|
||||
.add_blocked_domain(&body.domain, body.reason.as_deref())
|
||||
.await
|
||||
.map_err(ap_to_domain)?;
|
||||
.await?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/v1/admin/blocked-domains/{domain}",
|
||||
params(("domain" = String, Path, description = "Domain to unblock")),
|
||||
@@ -162,14 +138,13 @@ pub async fn remove_blocked_domain_admin(
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
state
|
||||
.app_ctx
|
||||
.services
|
||||
.ap_service
|
||||
.ap_blocklist
|
||||
.remove_blocked_domain(&domain)
|
||||
.await
|
||||
.map_err(ap_to_domain)?;
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/social/block",
|
||||
request_body = ActorUrlRequest,
|
||||
@@ -184,18 +159,19 @@ pub async fn block_actor_api(
|
||||
user: AuthenticatedUser,
|
||||
axum::Json(body): axum::Json<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let deps = SocialCommandDeps::from(&state);
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
application::social::execute::execute_command(
|
||||
&deps,
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::Block {
|
||||
blocker_id: user.0.value(),
|
||||
target: SocialIdentity::from_actor_url(&body.actor_url, &state.app_ctx.config.base_url),
|
||||
target: instance.identify(&body.actor_url),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/social/unblock",
|
||||
request_body = ActorUrlRequest,
|
||||
@@ -210,18 +186,19 @@ pub async fn unblock_actor_api(
|
||||
user: AuthenticatedUser,
|
||||
axum::Json(body): axum::Json<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let deps = SocialCommandDeps::from(&state);
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
application::social::execute::execute_command(
|
||||
&deps,
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::Unblock {
|
||||
blocker_id: user.0.value(),
|
||||
target: SocialIdentity::from_actor_url(&body.actor_url, &state.app_ctx.config.base_url),
|
||||
target: instance.identify(&body.actor_url),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/social/blocked",
|
||||
responses(
|
||||
@@ -234,18 +211,14 @@ pub async fn get_blocked_actors_api(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<Vec<BlockedActorResponse>>, ApiError> {
|
||||
let deps = SocialQueryDeps::from(&state);
|
||||
let identities = application::social::execute::execute_query(
|
||||
&deps,
|
||||
application::social::queries::SocialQry::GetBlocked {
|
||||
user_id: user.0.value(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
let identities =
|
||||
application::social::get_blocked::execute(&state.app_ctx.deps.social.query, user.0.value())
|
||||
.await?;
|
||||
Ok(Json(
|
||||
identities
|
||||
.into_iter()
|
||||
.map(social_actor_to_blocked_dto)
|
||||
.map(|a| social_actor_to_blocked_dto(a, &instance))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
@@ -262,16 +235,17 @@ pub async fn get_following(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<ActorListResponse>, ApiError> {
|
||||
let deps = SocialQueryDeps::from(&state);
|
||||
let identities = application::social::execute::execute_query(
|
||||
&deps,
|
||||
application::social::queries::SocialQry::GetFollowing {
|
||||
user_id: user.0.value(),
|
||||
},
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
let identities = application::social::get_following::execute(
|
||||
&state.app_ctx.deps.social.query,
|
||||
user.0.value(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ActorListResponse {
|
||||
actors: identities.into_iter().map(social_actor_to_dto).collect(),
|
||||
actors: identities
|
||||
.into_iter()
|
||||
.map(|a| social_actor_to_dto(a, &instance))
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -287,16 +261,17 @@ pub async fn get_followers(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<ActorListResponse>, ApiError> {
|
||||
let deps = SocialQueryDeps::from(&state);
|
||||
let identities = application::social::execute::execute_query(
|
||||
&deps,
|
||||
application::social::queries::SocialQry::GetFollowers {
|
||||
user_id: user.0.value(),
|
||||
},
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
let identities = application::social::get_followers::execute(
|
||||
&state.app_ctx.deps.social.query,
|
||||
user.0.value(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ActorListResponse {
|
||||
actors: identities.into_iter().map(social_actor_to_dto).collect(),
|
||||
actors: identities
|
||||
.into_iter()
|
||||
.map(|a| social_actor_to_dto(a, &instance))
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -305,14 +280,15 @@ pub async fn get_user_following(
|
||||
_user: AuthenticatedUser,
|
||||
Path(user_id): Path<Uuid>,
|
||||
) -> Result<Json<ActorListResponse>, ApiError> {
|
||||
let deps = SocialQueryDeps::from(&state);
|
||||
let identities = application::social::execute::execute_query(
|
||||
&deps,
|
||||
application::social::queries::SocialQry::GetFollowing { user_id },
|
||||
)
|
||||
.await?;
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
let identities =
|
||||
application::social::get_following::execute(&state.app_ctx.deps.social.query, user_id)
|
||||
.await?;
|
||||
Ok(Json(ActorListResponse {
|
||||
actors: identities.into_iter().map(social_actor_to_dto).collect(),
|
||||
actors: identities
|
||||
.into_iter()
|
||||
.map(|a| social_actor_to_dto(a, &instance))
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -321,14 +297,15 @@ pub async fn get_user_followers(
|
||||
_user: AuthenticatedUser,
|
||||
Path(user_id): Path<Uuid>,
|
||||
) -> Result<Json<ActorListResponse>, ApiError> {
|
||||
let deps = SocialQueryDeps::from(&state);
|
||||
let identities = application::social::execute::execute_query(
|
||||
&deps,
|
||||
application::social::queries::SocialQry::GetFollowers { user_id },
|
||||
)
|
||||
.await?;
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
let identities =
|
||||
application::social::get_followers::execute(&state.app_ctx.deps.social.query, user_id)
|
||||
.await?;
|
||||
Ok(Json(ActorListResponse {
|
||||
actors: identities.into_iter().map(social_actor_to_dto).collect(),
|
||||
actors: identities
|
||||
.into_iter()
|
||||
.map(|a| social_actor_to_dto(a, &instance))
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -346,9 +323,8 @@ pub async fn follow(
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<FollowRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let deps = SocialCommandDeps::from(&state);
|
||||
application::social::execute::execute_command(
|
||||
&deps,
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::Follow {
|
||||
follower_id: user.0.value(),
|
||||
target: FollowTarget::Handle(body.handle),
|
||||
@@ -372,12 +348,12 @@ pub async fn unfollow(
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let deps = SocialCommandDeps::from(&state);
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
application::social::execute::execute_command(
|
||||
&deps,
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::Unfollow {
|
||||
follower_id: user.0.value(),
|
||||
target: SocialIdentity::from_actor_url(&body.actor_url, &state.app_ctx.config.base_url),
|
||||
target: instance.identify(&body.actor_url),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -398,15 +374,12 @@ pub async fn accept_follower(
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let deps = SocialCommandDeps::from(&state);
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
application::social::execute::execute_command(
|
||||
&deps,
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::AcceptFollow {
|
||||
owner_id: user.0.value(),
|
||||
requester: SocialIdentity::from_actor_url(
|
||||
&body.actor_url,
|
||||
&state.app_ctx.config.base_url,
|
||||
),
|
||||
requester: instance.identify(&body.actor_url),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -427,15 +400,12 @@ pub async fn reject_follower(
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let deps = SocialCommandDeps::from(&state);
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
application::social::execute::execute_command(
|
||||
&deps,
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::RejectFollow {
|
||||
owner_id: user.0.value(),
|
||||
requester: SocialIdentity::from_actor_url(
|
||||
&body.actor_url,
|
||||
&state.app_ctx.config.base_url,
|
||||
),
|
||||
requester: instance.identify(&body.actor_url),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -456,15 +426,12 @@ pub async fn remove_follower(
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let deps = SocialCommandDeps::from(&state);
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
application::social::execute::execute_command(
|
||||
&deps,
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::RemoveFollower {
|
||||
owner_id: user.0.value(),
|
||||
follower: SocialIdentity::from_actor_url(
|
||||
&body.actor_url,
|
||||
&state.app_ctx.config.base_url,
|
||||
),
|
||||
follower: instance.identify(&body.actor_url),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -483,22 +450,101 @@ pub async fn get_pending_followers(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<ActorListResponse>, ApiError> {
|
||||
let deps = SocialQueryDeps::from(&state);
|
||||
let identities = application::social::execute::execute_query(
|
||||
&deps,
|
||||
application::social::queries::SocialQry::GetPending {
|
||||
user_id: user.0.value(),
|
||||
},
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
let identities = application::social::get_pending_followers::execute(
|
||||
&state.app_ctx.deps.social.query,
|
||||
user.0.value(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ActorListResponse {
|
||||
actors: identities.into_iter().map(social_actor_to_dto).collect(),
|
||||
actors: identities
|
||||
.into_iter()
|
||||
.map(|a| social_actor_to_dto(a, &instance))
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/social/followers/pending/count",
|
||||
responses(
|
||||
(status = 200, body = PendingCountResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_pending_follower_count(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<PendingCountResponse>, ApiError> {
|
||||
let count = application::social::count_pending_followers::execute(
|
||||
&state.app_ctx.deps.social.query,
|
||||
user.0.value(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(PendingCountResponse { count }))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/social/following/pending",
|
||||
responses(
|
||||
(status = 200, body = ActorListResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_pending_following(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<ActorListResponse>, ApiError> {
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
let actors = application::social::get_pending_following::execute(
|
||||
&state.app_ctx.deps.social.query,
|
||||
user.0.value(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ActorListResponse {
|
||||
actors: actors
|
||||
.into_iter()
|
||||
.map(|a| social_actor_to_dto(a, &instance))
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct RelationshipQuery {
|
||||
pub actor_url: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/social/relationship",
|
||||
params(("actor_url" = String, Query, description = "Canonical actor URL of the target")),
|
||||
responses(
|
||||
(status = 200, body = FollowRelationResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_relationship(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Query(q): Query<RelationshipQuery>,
|
||||
) -> Result<Json<FollowRelationResponse>, ApiError> {
|
||||
let target = state.app_ctx.instance.identify(&q.actor_url);
|
||||
let rel = application::social::get_relation::execute(
|
||||
&state.app_ctx.deps.social.query,
|
||||
user.0.value(),
|
||||
target,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(FollowRelationResponse {
|
||||
following: rel.following.into(),
|
||||
followed_by: rel.followed_by.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
// ── HTML ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn follow_remote_user(
|
||||
pub async fn follow_user(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(profile_user_uuid): Path<Uuid>,
|
||||
@@ -518,9 +564,8 @@ pub async fn follow_remote_user(
|
||||
.unwrap_or(&format!("/users/{}", profile_user_uuid))
|
||||
.to_string();
|
||||
|
||||
let deps = SocialCommandDeps::from(&state);
|
||||
match application::social::execute::execute_command(
|
||||
&deps,
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::Follow {
|
||||
follower_id: user_id.value(),
|
||||
target: FollowTarget::Handle(form.handle),
|
||||
@@ -542,7 +587,7 @@ pub async fn follow_remote_user(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn unfollow_remote_user(
|
||||
pub async fn unfollow_user(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(profile_user_uuid): Path<Uuid>,
|
||||
@@ -555,12 +600,12 @@ pub async fn unfollow_remote_user(
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let deps = SocialCommandDeps::from(&state);
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
match application::social::execute::execute_command(
|
||||
&deps,
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::Unfollow {
|
||||
follower_id: user_id.value(),
|
||||
target: SocialIdentity::from_actor_url(&form.actor_url, &state.app_ctx.config.base_url),
|
||||
target: instance.identify(&form.actor_url),
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -592,15 +637,12 @@ pub async fn accept_follower_html(
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let deps = SocialCommandDeps::from(&state);
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
match application::social::execute::execute_command(
|
||||
&deps,
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::AcceptFollow {
|
||||
owner_id: user_id.value(),
|
||||
requester: SocialIdentity::from_actor_url(
|
||||
&form.actor_url,
|
||||
&state.app_ctx.config.base_url,
|
||||
),
|
||||
requester: instance.identify(&form.actor_url),
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -626,15 +668,12 @@ pub async fn reject_follower_html(
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let deps = SocialCommandDeps::from(&state);
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
match application::social::execute::execute_command(
|
||||
&deps,
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::RejectFollow {
|
||||
owner_id: user_id.value(),
|
||||
requester: SocialIdentity::from_actor_url(
|
||||
&form.actor_url,
|
||||
&state.app_ctx.config.base_url,
|
||||
),
|
||||
requester: instance.identify(&form.actor_url),
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -647,6 +686,7 @@ pub async fn reject_follower_html(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
pub async fn get_followers_collection(
|
||||
State(state): State<AppState>,
|
||||
Path(user_id): Path<Uuid>,
|
||||
@@ -661,8 +701,7 @@ pub async fn get_followers_collection(
|
||||
let page = params.get("page").and_then(|p| p.parse::<u32>().ok());
|
||||
return match state
|
||||
.app_ctx
|
||||
.services
|
||||
.ap_service
|
||||
.ap_document
|
||||
.followers_collection_json(user_id, page)
|
||||
.await
|
||||
{
|
||||
@@ -674,12 +713,16 @@ pub async fn get_followers_collection(
|
||||
json,
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("followers_collection_json error: {:?}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
};
|
||||
}
|
||||
axum::response::Redirect::to(&format!("/users/{}/followers-list", user_id)).into_response()
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
pub async fn get_following_collection(
|
||||
State(state): State<AppState>,
|
||||
Path(user_id): Path<Uuid>,
|
||||
@@ -694,8 +737,7 @@ pub async fn get_following_collection(
|
||||
let page = params.get("page").and_then(|p| p.parse::<u32>().ok());
|
||||
return match state
|
||||
.app_ctx
|
||||
.services
|
||||
.ap_service
|
||||
.ap_document
|
||||
.following_collection_json(user_id, page)
|
||||
.await
|
||||
{
|
||||
@@ -707,7 +749,10 @@ pub async fn get_following_collection(
|
||||
json,
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("following_collection_json error: {:?}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
};
|
||||
}
|
||||
axum::response::Redirect::to(&format!("/users/{}/following-list", user_id)).into_response()
|
||||
@@ -729,24 +774,39 @@ pub async fn get_following_page(
|
||||
"{}/users/{}/following-list",
|
||||
state.app_ctx.config.base_url, profile_user_uuid
|
||||
);
|
||||
let deps = SocialQueryDeps::from(&state);
|
||||
match application::social::execute::execute_query(
|
||||
&deps,
|
||||
application::social::queries::SocialQry::GetFollowing {
|
||||
user_id: user_id.value(),
|
||||
},
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
match application::social::get_following::execute(
|
||||
&state.app_ctx.deps.social.query,
|
||||
user_id.value(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(following) => {
|
||||
let actors: Vec<RemoteActorData> = following
|
||||
.into_iter()
|
||||
.map(social_actor_to_template)
|
||||
.map(|a| social_actor_to_template(a, &instance))
|
||||
.collect();
|
||||
let pending_actors: Vec<RemoteActorData> =
|
||||
match application::social::get_pending_following::execute(
|
||||
&state.app_ctx.deps.social.query,
|
||||
user_id.value(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(pending) => pending
|
||||
.into_iter()
|
||||
.map(|a| social_actor_to_template(a, &instance))
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
tracing::error!("get_pending_following error: {:?}", e);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
render_page(FollowingTemplate {
|
||||
ctx,
|
||||
user_id: profile_user_uuid,
|
||||
actors,
|
||||
pending_actors,
|
||||
error: params.error,
|
||||
})
|
||||
.into_response()
|
||||
@@ -778,19 +838,17 @@ pub async fn get_followers_page(
|
||||
"{}/users/{}/followers-list",
|
||||
state.app_ctx.config.base_url, profile_user_uuid
|
||||
);
|
||||
let deps = SocialQueryDeps::from(&state);
|
||||
match application::social::execute::execute_query(
|
||||
&deps,
|
||||
application::social::queries::SocialQry::GetFollowers {
|
||||
user_id: user_id.value(),
|
||||
},
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
match application::social::get_followers::execute(
|
||||
&state.app_ctx.deps.social.query,
|
||||
user_id.value(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(followers) => {
|
||||
let actors: Vec<RemoteActorData> = followers
|
||||
.into_iter()
|
||||
.map(social_actor_to_template)
|
||||
.map(|a| social_actor_to_template(a, &instance))
|
||||
.collect();
|
||||
render_page(FollowersTemplate {
|
||||
ctx,
|
||||
@@ -824,15 +882,12 @@ pub async fn remove_follower_html(
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let deps = SocialCommandDeps::from(&state);
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
match application::social::execute::execute_command(
|
||||
&deps,
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::RemoveFollower {
|
||||
owner_id: user_id.value(),
|
||||
follower: SocialIdentity::from_actor_url(
|
||||
&form.actor_url,
|
||||
&state.app_ctx.config.base_url,
|
||||
),
|
||||
follower: instance.identify(&form.actor_url),
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -851,6 +906,7 @@ pub async fn remove_follower_html(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
pub async fn get_blocked_domains_page(
|
||||
crate::extractors::AdminUser(user_id): crate::extractors::AdminUser,
|
||||
State(state): State<AppState>,
|
||||
@@ -859,13 +915,7 @@ pub async fn get_blocked_domains_page(
|
||||
let mut ctx = build_page_context(&state, Some(user_id), csrf.0).await;
|
||||
ctx.page_title = "Blocked Domains — Movies Diary".to_string();
|
||||
ctx.canonical_url = format!("{}/admin/blocked-domains", state.app_ctx.config.base_url);
|
||||
match state
|
||||
.app_ctx
|
||||
.services
|
||||
.ap_service
|
||||
.get_blocked_domains()
|
||||
.await
|
||||
{
|
||||
match state.app_ctx.ap_blocklist.get_blocked_domains().await {
|
||||
Ok(domains) => {
|
||||
let entries: Vec<template_askama::BlockedDomainEntry> = domains
|
||||
.into_iter()
|
||||
@@ -892,6 +942,7 @@ pub async fn get_blocked_domains_page(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
pub async fn post_blocked_domain(
|
||||
crate::extractors::AdminUser(_): crate::extractors::AdminUser,
|
||||
State(state): State<AppState>,
|
||||
@@ -904,8 +955,7 @@ pub async fn post_blocked_domain(
|
||||
let reason = form.reason.as_deref().filter(|s| !s.trim().is_empty());
|
||||
match state
|
||||
.app_ctx
|
||||
.services
|
||||
.ap_service
|
||||
.ap_blocklist
|
||||
.add_blocked_domain(&form.domain, reason)
|
||||
.await
|
||||
{
|
||||
@@ -917,6 +967,7 @@ pub async fn post_blocked_domain(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
pub async fn post_remove_blocked_domain(
|
||||
crate::extractors::AdminUser(_): crate::extractors::AdminUser,
|
||||
State(state): State<AppState>,
|
||||
@@ -928,8 +979,7 @@ pub async fn post_remove_blocked_domain(
|
||||
}
|
||||
match state
|
||||
.app_ctx
|
||||
.services
|
||||
.ap_service
|
||||
.ap_blocklist
|
||||
.remove_blocked_domain(&form.domain)
|
||||
.await
|
||||
{
|
||||
@@ -941,6 +991,7 @@ pub async fn post_remove_blocked_domain(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
pub async fn get_blocked_actors_page(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
@@ -949,12 +1000,10 @@ pub async fn get_blocked_actors_page(
|
||||
let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await;
|
||||
ctx.page_title = "Blocked Users — Movies Diary".to_string();
|
||||
ctx.canonical_url = format!("{}/social/blocked", state.app_ctx.config.base_url);
|
||||
let deps = SocialQueryDeps::from(&state);
|
||||
match application::social::execute::execute_query(
|
||||
&deps,
|
||||
application::social::queries::SocialQry::GetBlocked {
|
||||
user_id: user_id.value(),
|
||||
},
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
match application::social::get_blocked::execute(
|
||||
&state.app_ctx.deps.social.query,
|
||||
user_id.value(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -962,7 +1011,7 @@ pub async fn get_blocked_actors_page(
|
||||
let entries: Vec<template_askama::BlockedActorEntry> = blocked
|
||||
.into_iter()
|
||||
.map(|a| template_askama::BlockedActorEntry {
|
||||
url: actor_url(&a.identity),
|
||||
url: instance.actor_url_of(&a.identity),
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
avatar_url: a.avatar_url,
|
||||
@@ -985,6 +1034,7 @@ pub async fn get_blocked_actors_page(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
pub async fn post_block_actor_html(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
@@ -994,12 +1044,12 @@ pub async fn post_block_actor_html(
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let deps = SocialCommandDeps::from(&state);
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
match application::social::execute::execute_command(
|
||||
&deps,
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::Block {
|
||||
blocker_id: user_id.value(),
|
||||
target: SocialIdentity::from_actor_url(&form.actor_url, &state.app_ctx.config.base_url),
|
||||
target: instance.identify(&form.actor_url),
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -1012,6 +1062,7 @@ pub async fn post_block_actor_html(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
pub async fn post_unblock_actor(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
@@ -1021,12 +1072,12 @@ pub async fn post_unblock_actor(
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let deps = SocialCommandDeps::from(&state);
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
match application::social::execute::execute_command(
|
||||
&deps,
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::Unblock {
|
||||
blocker_id: user_id.value(),
|
||||
target: SocialIdentity::from_actor_url(&form.actor_url, &state.app_ctx.config.base_url),
|
||||
target: instance.identify(&form.actor_url),
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -1038,3 +1089,110 @@ pub async fn post_unblock_actor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::value_objects::{InstanceIdentity, SocialActor, SocialIdentity, UserId};
|
||||
|
||||
fn local_actor(uid: &UserId) -> SocialActor {
|
||||
SocialActor {
|
||||
identity: SocialIdentity::Local(uid.clone()),
|
||||
handle: "@gabriel@md.example".into(),
|
||||
display_name: None,
|
||||
avatar_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The bug: a local actor used to serialize as `local:{uuid}`, which
|
||||
/// `identify` cannot parse, so every POST echoing this url back resolved
|
||||
/// to Remote and was routed to ActivityPub.
|
||||
#[test]
|
||||
fn local_actor_dto_url_round_trips_back_to_the_same_identity() {
|
||||
let instance = InstanceIdentity::new("https://md.example");
|
||||
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
|
||||
|
||||
let dto = social_actor_to_dto(local_actor(&uid), &instance);
|
||||
|
||||
assert!(!dto.url.starts_with("local:"), "url was {}", dto.url);
|
||||
assert_eq!(
|
||||
instance.identify(&dto.url),
|
||||
SocialIdentity::Local(uid),
|
||||
"a local actor's dto url must parse back as Local"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
#[test]
|
||||
fn local_blocked_dto_url_round_trips_back_to_the_same_identity() {
|
||||
let instance = InstanceIdentity::new("https://md.example");
|
||||
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
|
||||
|
||||
let dto = social_actor_to_blocked_dto(local_actor(&uid), &instance);
|
||||
|
||||
assert!(!dto.url.starts_with("local:"), "url was {}", dto.url);
|
||||
assert_eq!(instance.identify(&dto.url), SocialIdentity::Local(uid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_template_url_round_trips_back_to_the_same_identity() {
|
||||
let instance = InstanceIdentity::new("https://md.example");
|
||||
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
|
||||
|
||||
let data = social_actor_to_template(local_actor(&uid), &instance);
|
||||
|
||||
assert!(!data.url.starts_with("local:"), "url was {}", data.url);
|
||||
assert_eq!(instance.identify(&data.url), SocialIdentity::Local(uid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_actor_dto_url_is_passed_through_unchanged() {
|
||||
let instance = InstanceIdentity::new("https://md.example");
|
||||
let actor = SocialActor {
|
||||
identity: SocialIdentity::Remote {
|
||||
actor_url: "https://other.example/users/bob".into(),
|
||||
},
|
||||
handle: "@bob@other.example".into(),
|
||||
display_name: None,
|
||||
avatar_url: None,
|
||||
};
|
||||
|
||||
let dto = social_actor_to_dto(actor, &instance);
|
||||
|
||||
assert_eq!(dto.url, "https://other.example/users/bob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_actor_dto_carries_user_id_and_avatar() {
|
||||
let instance = InstanceIdentity::new("https://md.example");
|
||||
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
|
||||
let mut actor = local_actor(&uid);
|
||||
actor.avatar_url = Some("https://md.example/images/a.webp".into());
|
||||
|
||||
let dto = social_actor_to_dto(actor, &instance);
|
||||
|
||||
assert_eq!(
|
||||
dto.user_id,
|
||||
Some(uid.value()),
|
||||
"local actors must carry user_id for internal links"
|
||||
);
|
||||
assert_eq!(
|
||||
dto.avatar_url.as_deref(),
|
||||
Some("https://md.example/images/a.webp")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_actor_dto_has_no_user_id() {
|
||||
let instance = InstanceIdentity::new("https://md.example");
|
||||
let actor = SocialActor {
|
||||
identity: SocialIdentity::Remote {
|
||||
actor_url: "https://other.example/users/bob".into(),
|
||||
},
|
||||
handle: "@bob@other.example".into(),
|
||||
display_name: None,
|
||||
avatar_url: None,
|
||||
};
|
||||
assert_eq!(social_actor_to_dto(actor, &instance).user_id, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@ use axum::{
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::users::{
|
||||
deps::{GetProfileDeps, UpdateProfileDeps},
|
||||
get_profile as get_user_profile_uc, get_users,
|
||||
get_federated_profile, get_federated_profile_stats, get_local_profile,
|
||||
get_profile_settings as get_profile_settings_uc, get_users,
|
||||
queries::{GetUserProfileQuery, GetUsersQuery},
|
||||
update_profile, update_profile_fields,
|
||||
resolve_username_to_id, update_profile, update_profile_fields,
|
||||
};
|
||||
use domain::value_objects::UserId;
|
||||
use domain::{errors::DomainError, value_objects::Username};
|
||||
|
||||
use crate::{
|
||||
csrf::CsrfToken,
|
||||
@@ -53,24 +53,20 @@ pub async fn get_profile(
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
) -> Result<Json<ProfileResponse>, ApiError> {
|
||||
let profile = application::users::get_current_profile::execute(
|
||||
state.app_ctx.repos.user.clone(),
|
||||
&state.app_ctx.deps.users.get_current_profile,
|
||||
application::users::queries::GetCurrentProfileQuery {
|
||||
user_id: user_id.value(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let base_url = &state.app_ctx.config.base_url;
|
||||
let instance = &state.app_ctx.instance;
|
||||
Ok(Json(ProfileResponse {
|
||||
profile: api_types::UserProfileBase {
|
||||
username: profile.username,
|
||||
display_name: profile.display_name,
|
||||
bio: profile.bio,
|
||||
avatar_url: profile
|
||||
.avatar_path
|
||||
.map(|p| format!("{}/images/{}", base_url, p)),
|
||||
banner_url: profile
|
||||
.banner_path
|
||||
.map(|p| format!("{}/images/{}", base_url, p)),
|
||||
avatar_url: profile.avatar_path.map(|p| instance.image_url_for(&p)),
|
||||
banner_url: profile.banner_path.map(|p| instance.image_url_for(&p)),
|
||||
},
|
||||
also_known_as: profile.also_known_as,
|
||||
fields: profile
|
||||
@@ -113,12 +109,7 @@ pub async fn update_profile_handler(
|
||||
also_known_as: data.also_known_as,
|
||||
};
|
||||
|
||||
let deps = UpdateProfileDeps {
|
||||
user: state.app_ctx.repos.user.clone(),
|
||||
object_storage: state.app_ctx.services.object_storage.clone(),
|
||||
event_publisher: state.app_ctx.services.event_publisher.clone(),
|
||||
};
|
||||
match update_profile::execute(&deps, cmd).await {
|
||||
match update_profile::execute(&state.app_ctx.deps.users.update_profile, cmd).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => crate::errors::domain_error_response(e),
|
||||
}
|
||||
@@ -159,12 +150,7 @@ pub async fn update_profile_fields_handler(
|
||||
fields,
|
||||
};
|
||||
|
||||
match update_profile_fields::execute(
|
||||
state.app_ctx.repos.profile_fields.clone(),
|
||||
state.app_ctx.services.event_publisher.clone(),
|
||||
cmd,
|
||||
)
|
||||
.await
|
||||
match update_profile_fields::execute(&state.app_ctx.deps.users.update_profile_fields, cmd).await
|
||||
{
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => crate::errors::domain_error_response(e),
|
||||
@@ -176,11 +162,8 @@ pub async fn update_profile_fields_handler(
|
||||
responses((status = 200, body = UsersResponse)),
|
||||
)]
|
||||
pub async fn list_users(State(state): State<AppState>) -> Result<Json<UsersResponse>, ApiError> {
|
||||
let deps = application::users::deps::GetUsersListDeps {
|
||||
user: state.app_ctx.repos.user.clone(),
|
||||
federation_admin: state.app_ctx.repos.federation_admin.clone(),
|
||||
};
|
||||
let result = get_users::execute(&deps, GetUsersQuery).await?;
|
||||
let result =
|
||||
get_users::execute(&state.app_ctx.deps.users.get_users_list, GetUsersQuery).await?;
|
||||
Ok(Json(UsersResponse {
|
||||
users: result
|
||||
.users
|
||||
@@ -220,44 +203,15 @@ pub async fn get_user_profile(
|
||||
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
|
||||
let local_user = match state
|
||||
.app_ctx
|
||||
.repos
|
||||
.user
|
||||
.find_by_id(&UserId::from_uuid(user_id))
|
||||
.await
|
||||
{
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
return crate::errors::domain_error_response(e);
|
||||
}
|
||||
};
|
||||
|
||||
if local_user.is_none() {
|
||||
if let Some(ref fed_query) = state.app_ctx.repos.federated_profile
|
||||
&& let Ok(Some(fed)) = fed_query.get_federated_profile(user_id).await
|
||||
{
|
||||
return build_federated_profile_response(&state, user_id, fed, profile_view, ¶ms)
|
||||
.await;
|
||||
}
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
let user = local_user.unwrap();
|
||||
|
||||
let get_profile_deps = GetProfileDeps {
|
||||
stats: state.app_ctx.repos.stats.clone(),
|
||||
diary: state.app_ctx.repos.diary.clone(),
|
||||
social_query: state.app_ctx.repos.social_query_unified.clone(),
|
||||
};
|
||||
let profile = match get_user_profile_uc::execute(
|
||||
&get_profile_deps,
|
||||
let profile = match get_local_profile::execute(
|
||||
&state.app_ctx.deps.users.get_local_profile,
|
||||
GetUserProfileQuery {
|
||||
user_id,
|
||||
view: profile_view,
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
sort_by: domain::models::FeedSortBy::Date,
|
||||
search: params.search,
|
||||
search: params.search.clone(),
|
||||
is_own_profile: viewer_id.value() == user_id,
|
||||
include_remote: false,
|
||||
},
|
||||
@@ -265,6 +219,24 @@ pub async fn get_user_profile(
|
||||
.await
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(DomainError::NotFound(_)) => {
|
||||
if let Ok(Some(fed)) = get_federated_profile::execute(
|
||||
&state.app_ctx.deps.users.get_federated_profile,
|
||||
user_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return build_federated_profile_response(
|
||||
&state,
|
||||
user_id,
|
||||
fed,
|
||||
profile_view,
|
||||
¶ms,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
Err(e) => return crate::errors::domain_error_response(e),
|
||||
};
|
||||
|
||||
@@ -306,15 +278,11 @@ pub async fn get_user_profile(
|
||||
Json(UserProfileResponse {
|
||||
user_id,
|
||||
profile: api_types::UserProfileBase {
|
||||
username: user.username().value().to_string(),
|
||||
avatar_url: user
|
||||
.avatar_path()
|
||||
.map(|p| format!("{}/images/{}", state.app_ctx.config.base_url, p)),
|
||||
banner_url: user
|
||||
.banner_path()
|
||||
.map(|p| format!("{}/images/{}", state.app_ctx.config.base_url, p)),
|
||||
display_name: None,
|
||||
bio: None,
|
||||
username: profile.identity.username.clone(),
|
||||
avatar_url: profile.identity.avatar_url.clone(),
|
||||
banner_url: profile.identity.banner_url.clone(),
|
||||
display_name: profile.identity.display_name.clone(),
|
||||
bio: profile.identity.bio.clone(),
|
||||
},
|
||||
stats: UserStatsDto {
|
||||
total_movies: profile.stats.total_movies,
|
||||
@@ -327,10 +295,7 @@ pub async fn get_user_profile(
|
||||
view_data,
|
||||
goals: {
|
||||
let goals_list = application::goals::list::execute(
|
||||
&application::goals::deps::GoalQueryDeps {
|
||||
goal_query: state.app_ctx.repos.goal_query.clone(),
|
||||
stats: state.app_ctx.repos.stats.clone(),
|
||||
},
|
||||
&state.app_ctx.deps.goals.query,
|
||||
application::goals::queries::ListGoalsQuery { user_id },
|
||||
)
|
||||
.await
|
||||
@@ -342,8 +307,8 @@ pub async fn get_user_profile(
|
||||
}
|
||||
},
|
||||
is_federated: false,
|
||||
handle: None,
|
||||
actor_url: None,
|
||||
handle: Some(profile.identity.handle.clone()),
|
||||
actor_url: Some(profile.identity.actor_url.clone()),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
@@ -355,13 +320,8 @@ async fn build_federated_profile_response(
|
||||
profile_view: application::users::queries::ProfileView,
|
||||
params: &UserProfileQueryParams,
|
||||
) -> axum::response::Response {
|
||||
let get_profile_deps = GetProfileDeps {
|
||||
stats: state.app_ctx.repos.stats.clone(),
|
||||
diary: state.app_ctx.repos.diary.clone(),
|
||||
social_query: state.app_ctx.repos.social_query_unified.clone(),
|
||||
};
|
||||
let profile = match get_user_profile_uc::execute(
|
||||
&get_profile_deps,
|
||||
let profile = match get_federated_profile_stats::execute(
|
||||
&state.app_ctx.deps.users.get_federated_profile_stats,
|
||||
GetUserProfileQuery {
|
||||
user_id,
|
||||
view: profile_view,
|
||||
@@ -481,12 +441,8 @@ pub async fn get_users_list(
|
||||
ctx.page_title = "Members — Movies Diary".to_string();
|
||||
ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url);
|
||||
|
||||
let users_deps = application::users::deps::GetUsersListDeps {
|
||||
user: state.app_ctx.repos.user.clone(),
|
||||
federation_admin: state.app_ctx.repos.federation_admin.clone(),
|
||||
};
|
||||
match application::users::get_users::execute(
|
||||
&users_deps,
|
||||
&state.app_ctx.deps.users.get_users_list,
|
||||
application::users::queries::GetUsersQuery,
|
||||
)
|
||||
.await
|
||||
@@ -517,14 +473,14 @@ pub async fn get_user_by_username(
|
||||
State(state): State<AppState>,
|
||||
Path(username): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let uname = match domain::value_objects::Username::new(username) {
|
||||
let uname = match Username::new(username) {
|
||||
Ok(u) => u,
|
||||
Err(_) => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
match state.app_ctx.repos.user.find_by_username(&uname).await {
|
||||
Ok(Some(user)) => {
|
||||
axum::response::Redirect::permanent(&format!("/users/{}", user.id().value()))
|
||||
.into_response()
|
||||
match resolve_username_to_id::execute(&state.app_ctx.deps.users.resolve_username, &uname).await
|
||||
{
|
||||
Ok(Some(uid)) => {
|
||||
axum::response::Redirect::permanent(&format!("/users/{}", uid.value())).into_response()
|
||||
}
|
||||
_ => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
@@ -607,10 +563,7 @@ async fn fetch_profile_goals(
|
||||
user_id: Uuid,
|
||||
) -> Vec<template_askama::GoalViewData> {
|
||||
let goals_list = application::goals::list::execute(
|
||||
&application::goals::deps::GoalQueryDeps {
|
||||
goal_query: state.app_ctx.repos.goal_query.clone(),
|
||||
stats: state.app_ctx.repos.stats.clone(),
|
||||
},
|
||||
&state.app_ctx.deps.goals.query,
|
||||
application::goals::queries::ListGoalsQuery { user_id },
|
||||
)
|
||||
.await
|
||||
@@ -647,8 +600,7 @@ pub async fn get_user_profile_html(
|
||||
if accept.contains("application/activity+json") || accept.contains("application/ld+json") {
|
||||
return match state
|
||||
.app_ctx
|
||||
.services
|
||||
.ap_service
|
||||
.ap_document
|
||||
.actor_json(&profile_user_uuid.to_string())
|
||||
.await
|
||||
{
|
||||
@@ -681,25 +633,6 @@ pub async fn get_user_profile_html(
|
||||
}
|
||||
};
|
||||
|
||||
let profile_user = match state
|
||||
.app_ctx
|
||||
.repos
|
||||
.user
|
||||
.find_by_id(&domain::value_objects::UserId::from_uuid(profile_user_uuid))
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
|
||||
Err(e) => return crate::errors::domain_error_response(e),
|
||||
};
|
||||
|
||||
let display_name = profile_user.username().value();
|
||||
ctx.page_title = format!("{}'s Diary — Movies Diary", display_name);
|
||||
ctx.canonical_url = format!(
|
||||
"{}/users/{}",
|
||||
state.app_ctx.config.base_url, profile_user_uuid
|
||||
);
|
||||
|
||||
let sort_by_str = match params.sort_by.as_str() {
|
||||
"date_asc" => "date_asc",
|
||||
"rating" => "rating",
|
||||
@@ -727,19 +660,25 @@ pub async fn get_user_profile_html(
|
||||
include_remote: false,
|
||||
};
|
||||
|
||||
let html_profile_deps = GetProfileDeps {
|
||||
stats: state.app_ctx.repos.stats.clone(),
|
||||
diary: state.app_ctx.repos.diary.clone(),
|
||||
social_query: state.app_ctx.repos.social_query_unified.clone(),
|
||||
};
|
||||
match application::users::get_profile::execute(&html_profile_deps, query).await {
|
||||
match get_local_profile::execute(&state.app_ctx.deps.users.get_local_profile, query).await {
|
||||
Ok(profile) => {
|
||||
ctx.page_title = format!("{}'s Diary — Movies Diary", profile.identity.username);
|
||||
ctx.canonical_url = format!(
|
||||
"{}/users/{}",
|
||||
state.app_ctx.config.base_url, profile_user_uuid
|
||||
);
|
||||
|
||||
let pag = compute_pagination(profile.entries.as_ref());
|
||||
if !is_own_profile {
|
||||
ctx.page_rss_url = Some(format!("/users/{}/feed.rss", profile_user_uuid));
|
||||
}
|
||||
let email = profile_user.email().value().to_string();
|
||||
let display_name = email.split('@').next().unwrap_or("?").to_string();
|
||||
let display_name = profile
|
||||
.identity
|
||||
.email
|
||||
.split('@')
|
||||
.next()
|
||||
.unwrap_or("?")
|
||||
.to_string();
|
||||
let stats_disp = build_stats_display(&profile.stats);
|
||||
let history = profile.history.map(application::users::group_by_month);
|
||||
let heatmap = history.as_deref().map(build_heatmap).unwrap_or_default();
|
||||
@@ -809,6 +748,7 @@ pub async fn get_user_profile_html(
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(e) => crate::errors::domain_error_response(e),
|
||||
}
|
||||
}
|
||||
@@ -828,43 +768,26 @@ pub async fn get_profile_settings(
|
||||
ctx.page_title = "Profile Settings — Movies Diary".to_string();
|
||||
ctx.canonical_url = format!("{}/settings/profile", state.app_ctx.config.base_url);
|
||||
|
||||
let user = match state.app_ctx.repos.user.find_by_id(&user_id).await {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
|
||||
let settings = match get_profile_settings_uc::execute(
|
||||
&state.app_ctx.deps.users.get_profile_settings,
|
||||
user_id.value(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(DomainError::NotFound(_)) => return StatusCode::NOT_FOUND.into_response(),
|
||||
Err(e) => return crate::errors::domain_error_response(e),
|
||||
};
|
||||
|
||||
let base_url = &state.app_ctx.config.base_url;
|
||||
let avatar_url = user
|
||||
.avatar_path()
|
||||
.map(|path| format!("{}/images/{}", base_url, path));
|
||||
let banner_url = user
|
||||
.banner_path()
|
||||
.map(|path| format!("{}/images/{}", base_url, path));
|
||||
|
||||
let profile_fields: Vec<(String, String)> = state
|
||||
.app_ctx
|
||||
.repos
|
||||
.profile_fields
|
||||
.get_fields(&user_id)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|f| (f.name, f.value))
|
||||
.collect();
|
||||
|
||||
let saved = params.saved.as_deref() == Some("1");
|
||||
|
||||
let bio = user.bio().map(|s| s.to_string());
|
||||
let also_known_as = user.also_known_as().map(|s| s.to_string());
|
||||
|
||||
render_page(ProfileSettingsTemplate {
|
||||
ctx: &ctx,
|
||||
bio: bio.as_deref(),
|
||||
avatar_url: avatar_url.as_deref(),
|
||||
banner_url: banner_url.as_deref(),
|
||||
also_known_as: also_known_as.as_deref(),
|
||||
profile_fields: &profile_fields,
|
||||
bio: settings.bio.as_deref(),
|
||||
avatar_url: settings.avatar_url.as_deref(),
|
||||
banner_url: settings.banner_url.as_deref(),
|
||||
also_known_as: settings.also_known_as.as_deref(),
|
||||
profile_fields: &settings.fields,
|
||||
saved,
|
||||
embed_url: format!(
|
||||
"{}/users/{}?embed=true",
|
||||
@@ -892,12 +815,7 @@ pub async fn post_profile_settings(
|
||||
banner_content_type: data.banner_content_type,
|
||||
also_known_as: data.also_known_as,
|
||||
};
|
||||
let update_deps = UpdateProfileDeps {
|
||||
user: state.app_ctx.repos.user.clone(),
|
||||
object_storage: state.app_ctx.services.object_storage.clone(),
|
||||
event_publisher: state.app_ctx.services.event_publisher.clone(),
|
||||
};
|
||||
if let Err(e) = update_profile::execute(&update_deps, cmd).await {
|
||||
if let Err(e) = update_profile::execute(&state.app_ctx.deps.users.update_profile, cmd).await {
|
||||
tracing::error!("update_profile error: {:?}", e);
|
||||
return axum::response::Redirect::to(&format!(
|
||||
"/settings/profile?error={}",
|
||||
@@ -925,12 +843,9 @@ pub async fn post_profile_settings(
|
||||
user_id: user_id.value(),
|
||||
fields,
|
||||
};
|
||||
if let Err(e) = update_profile_fields::execute(
|
||||
state.app_ctx.repos.profile_fields.clone(),
|
||||
state.app_ctx.services.event_publisher.clone(),
|
||||
fields_cmd,
|
||||
)
|
||||
.await
|
||||
if let Err(e) =
|
||||
update_profile_fields::execute(&state.app_ctx.deps.users.update_profile_fields, fields_cmd)
|
||||
.await
|
||||
{
|
||||
tracing::error!("update_profile_fields error: {:?}", e);
|
||||
return axum::response::Redirect::to(&format!(
|
||||
|
||||
@@ -11,8 +11,7 @@ use application::{
|
||||
watchlist::{
|
||||
add as add_to_watchlist,
|
||||
commands::{AddToWatchlistCommand, RemoveFromWatchlistCommand},
|
||||
deps::WatchlistAddDeps,
|
||||
get as get_watchlist, is_on as is_on_watchlist,
|
||||
get as get_watchlist, get_watchlist_for_owner, is_on as is_on_watchlist,
|
||||
queries::{GetWatchlistQuery, IsOnWatchlistQuery},
|
||||
remove as remove_from_watchlist,
|
||||
},
|
||||
@@ -54,7 +53,7 @@ pub async fn get_watchlist_handler(
|
||||
Query(params): Query<PaginationQueryParams>,
|
||||
) -> Result<Json<WatchlistResponse>, ApiError> {
|
||||
let page = get_watchlist::execute(
|
||||
state.app_ctx.repos.watchlist.clone(),
|
||||
&state.app_ctx.deps.watchlist.get_watchlist,
|
||||
GetWatchlistQuery {
|
||||
user_id: user.0.value(),
|
||||
limit: params.limit,
|
||||
@@ -94,15 +93,8 @@ pub async fn post_watchlist_add(
|
||||
user: AuthenticatedUser,
|
||||
Json(req): Json<AddToWatchlistRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let deps = WatchlistAddDeps {
|
||||
movie_command: state.app_ctx.repos.movie_command.clone(),
|
||||
movie_query: state.app_ctx.repos.movie_query.clone(),
|
||||
metadata: state.app_ctx.services.metadata.clone(),
|
||||
watchlist: state.app_ctx.repos.watchlist.clone(),
|
||||
event_publisher: state.app_ctx.services.event_publisher.clone(),
|
||||
};
|
||||
add_to_watchlist::execute(
|
||||
&deps,
|
||||
&state.app_ctx.deps.watchlist.add,
|
||||
AddToWatchlistCommand {
|
||||
user_id: user.0.value(),
|
||||
input: MovieInput {
|
||||
@@ -134,8 +126,7 @@ pub async fn delete_watchlist_entry(
|
||||
Path(movie_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
remove_from_watchlist::execute(
|
||||
state.app_ctx.repos.watchlist.clone(),
|
||||
state.app_ctx.services.event_publisher.clone(),
|
||||
&state.app_ctx.deps.watchlist.remove_from_watchlist,
|
||||
RemoveFromWatchlistCommand {
|
||||
user_id: user.0.value(),
|
||||
movie_id,
|
||||
@@ -160,7 +151,7 @@ pub async fn get_watchlist_status(
|
||||
Path(movie_id): Path<Uuid>,
|
||||
) -> Result<Json<WatchlistStatusResponse>, ApiError> {
|
||||
let on_watchlist = is_on_watchlist::execute(
|
||||
state.app_ctx.repos.watchlist.clone(),
|
||||
&state.app_ctx.deps.watchlist.is_on_watchlist,
|
||||
IsOnWatchlistQuery {
|
||||
user_id: user.0.value(),
|
||||
movie_id,
|
||||
@@ -182,39 +173,25 @@ pub async fn get_watchlist_page(
|
||||
let ctx = build_page_context(&state, viewer_id.clone(), csrf.0).await;
|
||||
let is_owner = viewer_id.map(|u| u.value() == owner_id).unwrap_or(false);
|
||||
|
||||
let user_id = domain::value_objects::UserId::from_uuid(owner_id);
|
||||
let is_local = state
|
||||
.app_ctx
|
||||
.repos
|
||||
.user
|
||||
.find_by_id(&user_id)
|
||||
.await
|
||||
.map(|u| u.is_some())
|
||||
.unwrap_or(false);
|
||||
let view = match get_watchlist_for_owner::execute(
|
||||
&state.app_ctx.deps.watchlist.get_watchlist_for_owner,
|
||||
owner_id,
|
||||
params.limit.or(Some(20)),
|
||||
params.offset.or(Some(0)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(view) => view,
|
||||
Err(e) => return crate::errors::domain_error_response(e),
|
||||
};
|
||||
|
||||
let result = if is_local {
|
||||
match get_watchlist::execute(
|
||||
state.app_ctx.repos.watchlist.clone(),
|
||||
application::watchlist::queries::GetWatchlistQuery {
|
||||
user_id: owner_id,
|
||||
limit: params.limit.or(Some(20)),
|
||||
offset: params.offset.or(Some(0)),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(page) => crate::mappers::watchlist::build_watchlist_page(page, is_owner),
|
||||
Err(e) => return crate::errors::domain_error_response(e),
|
||||
let result = match view {
|
||||
get_watchlist_for_owner::WatchlistView::Local(page) => {
|
||||
crate::mappers::watchlist::build_watchlist_page(page, is_owner)
|
||||
}
|
||||
get_watchlist_for_owner::WatchlistView::Remote(entries) => {
|
||||
crate::mappers::watchlist::build_remote_watchlist_page(entries)
|
||||
}
|
||||
} else {
|
||||
let remote_entries = state
|
||||
.app_ctx
|
||||
.repos
|
||||
.remote_watchlist
|
||||
.get_by_derived_uuid(owner_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
crate::mappers::watchlist::build_remote_watchlist_page(remote_entries)
|
||||
};
|
||||
|
||||
render_page(WatchlistTemplate {
|
||||
@@ -280,16 +257,8 @@ pub async fn post_watchlist_add_html(
|
||||
}
|
||||
};
|
||||
|
||||
let deps = WatchlistAddDeps {
|
||||
movie_command: state.app_ctx.repos.movie_command.clone(),
|
||||
movie_query: state.app_ctx.repos.movie_query.clone(),
|
||||
metadata: state.app_ctx.services.metadata.clone(),
|
||||
watchlist: state.app_ctx.repos.watchlist.clone(),
|
||||
event_publisher: state.app_ctx.services.event_publisher.clone(),
|
||||
};
|
||||
|
||||
match add_to_watchlist::execute(
|
||||
&deps,
|
||||
&state.app_ctx.deps.watchlist.add,
|
||||
AddToWatchlistCommand {
|
||||
user_id: user_id.value(),
|
||||
input,
|
||||
@@ -323,8 +292,7 @@ pub async fn post_watchlist_remove_html(
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
match remove_from_watchlist::execute(
|
||||
state.app_ctx.repos.watchlist.clone(),
|
||||
state.app_ctx.services.event_publisher.clone(),
|
||||
&state.app_ctx.deps.watchlist.remove_from_watchlist,
|
||||
RemoveFromWatchlistCommand {
|
||||
user_id: user_id.value(),
|
||||
movie_id,
|
||||
|
||||
@@ -15,10 +15,9 @@ use application::integrations::{
|
||||
ConfirmWatchEventsCommand, DismissWatchEventsCommand, GenerateWebhookTokenCommand,
|
||||
IngestWatchEventCommand, RevokeWebhookTokenCommand, WatchEventConfirmation,
|
||||
},
|
||||
confirm as confirm_watch_events,
|
||||
deps::IngestWatchEventDeps,
|
||||
dismiss as dismiss_watch_events, generate_token as generate_webhook_token,
|
||||
get_queue as get_watch_queue, get_tokens as get_webhook_tokens, ingest as ingest_watch_event,
|
||||
confirm as confirm_watch_events, dismiss as dismiss_watch_events,
|
||||
generate_token as generate_webhook_token, get_queue as get_watch_queue,
|
||||
get_tokens as get_webhook_tokens, ingest as ingest_watch_event,
|
||||
queries::{GetWatchQueueQuery, GetWebhookTokensQuery},
|
||||
revoke_token as revoke_webhook_token,
|
||||
};
|
||||
@@ -72,7 +71,12 @@ pub async fn post_jellyfin_webhook(
|
||||
source: WatchEventSource::Jellyfin,
|
||||
};
|
||||
|
||||
run_ingest(&state, cmd, &jellyfin::JellyfinParser).await
|
||||
run_ingest(
|
||||
&state,
|
||||
cmd,
|
||||
&*state.app_ctx.deps.integrations.jellyfin_parser,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// ── Plex webhook (multipart form data with `payload` JSON field) ──────────────
|
||||
@@ -119,7 +123,7 @@ pub async fn post_plex_webhook(
|
||||
source: WatchEventSource::Plex,
|
||||
};
|
||||
|
||||
run_ingest(&state, cmd, &plex::PlexParser).await
|
||||
run_ingest(&state, cmd, &*state.app_ctx.deps.integrations.plex_parser).await
|
||||
}
|
||||
|
||||
async fn run_ingest(
|
||||
@@ -127,13 +131,13 @@ async fn run_ingest(
|
||||
cmd: IngestWatchEventCommand,
|
||||
parser: &dyn domain::ports::MediaServerParser,
|
||||
) -> StatusCode {
|
||||
let deps = IngestWatchEventDeps {
|
||||
webhook_token: state.app_ctx.repos.webhook_token.clone(),
|
||||
watch_event_command: state.app_ctx.repos.watch_event_command.clone(),
|
||||
watch_event_query: state.app_ctx.repos.watch_event_query.clone(),
|
||||
event_publisher: state.app_ctx.services.event_publisher.clone(),
|
||||
};
|
||||
match ingest_watch_event::execute(&deps, cmd, parser).await {
|
||||
match ingest_watch_event::execute(
|
||||
&state.app_ctx.deps.integrations.ingest_watch_event,
|
||||
cmd,
|
||||
parser,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => StatusCode::OK,
|
||||
Err(e) => crate::errors::domain_error_status(&e),
|
||||
}
|
||||
@@ -166,8 +170,11 @@ pub async fn post_generate_webhook_token(
|
||||
label: req.label,
|
||||
};
|
||||
|
||||
let result =
|
||||
generate_webhook_token::execute(state.app_ctx.repos.webhook_token.clone(), cmd).await?;
|
||||
let result = generate_webhook_token::execute(
|
||||
&state.app_ctx.deps.integrations.generate_webhook_token,
|
||||
cmd,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let base_url = &state.app_ctx.config.base_url;
|
||||
let webhook_url = format!("{base_url}/api/v1/webhooks/{provider}");
|
||||
@@ -195,7 +202,8 @@ pub async fn get_webhook_tokens(
|
||||
user_id: user.0.value(),
|
||||
};
|
||||
let tokens =
|
||||
get_webhook_tokens::execute(state.app_ctx.repos.webhook_token.clone(), query).await?;
|
||||
get_webhook_tokens::execute(&state.app_ctx.deps.integrations.get_webhook_tokens, query)
|
||||
.await?;
|
||||
|
||||
let dtos = tokens
|
||||
.into_iter()
|
||||
@@ -230,7 +238,8 @@ pub async fn delete_webhook_token(
|
||||
user_id: user.0.value(),
|
||||
token_id: id,
|
||||
};
|
||||
revoke_webhook_token::execute(state.app_ctx.repos.webhook_token.clone(), cmd).await?;
|
||||
revoke_webhook_token::execute(&state.app_ctx.deps.integrations.revoke_webhook_token, cmd)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -252,7 +261,7 @@ pub async fn get_watch_queue(
|
||||
user_id: user.0.value(),
|
||||
};
|
||||
let events =
|
||||
get_watch_queue::execute(state.app_ctx.repos.watch_event_query.clone(), query).await?;
|
||||
get_watch_queue::execute(&state.app_ctx.deps.integrations.get_watch_queue, query).await?;
|
||||
|
||||
let dtos = events
|
||||
.into_iter()
|
||||
@@ -297,13 +306,9 @@ pub async fn post_confirm_watch_events(
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let confirmed = confirm_watch_events::execute(
|
||||
state.app_ctx.repos.watch_event_command.clone(),
|
||||
state.app_ctx.repos.watch_event_query.clone(),
|
||||
state.app_ctx.services.review_logger.clone(),
|
||||
cmd,
|
||||
)
|
||||
.await?;
|
||||
let confirmed =
|
||||
confirm_watch_events::execute(&state.app_ctx.deps.integrations.confirm_watch_events, cmd)
|
||||
.await?;
|
||||
Ok(Json(ConfirmWatchResponse { confirmed }))
|
||||
}
|
||||
|
||||
@@ -327,11 +332,8 @@ pub async fn post_dismiss_watch_events(
|
||||
event_ids: req.event_ids,
|
||||
};
|
||||
|
||||
let dismissed = dismiss_watch_events::execute(
|
||||
state.app_ctx.repos.watch_event_command.clone(),
|
||||
state.app_ctx.repos.watch_event_query.clone(),
|
||||
cmd,
|
||||
)
|
||||
.await?;
|
||||
let dismissed =
|
||||
dismiss_watch_events::execute(&state.app_ctx.deps.integrations.dismiss_watch_events, cmd)
|
||||
.await?;
|
||||
Ok(Json(DismissWatchResponse { dismissed }))
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@ use uuid::Uuid;
|
||||
|
||||
use application::wrapup::{
|
||||
commands::RequestWrapUpCommand,
|
||||
delete as delete_wrapup, generate, get_wrapup,
|
||||
delete as delete_wrapup, generate, get_ready_report, get_wrapup,
|
||||
list_wrapups::{self, ListWrapUpsQuery},
|
||||
};
|
||||
use domain::errors::DomainError;
|
||||
use domain::models::wrapup::{WrapUpRecord, WrapUpReport, WrapUpStatus};
|
||||
use domain::models::wrapup::{WrapUpRecord, WrapUpReport, WrapUpScope, WrapUpStatus};
|
||||
use domain::value_objects::WrapUpId;
|
||||
|
||||
use crate::{
|
||||
@@ -66,12 +66,7 @@ pub async fn post_generate(
|
||||
start_date: start,
|
||||
end_date: end,
|
||||
};
|
||||
let id = generate::execute(
|
||||
state.app_ctx.repos.wrapup_repo.clone(),
|
||||
state.app_ctx.services.event_publisher.clone(),
|
||||
cmd,
|
||||
)
|
||||
.await?;
|
||||
let id = generate::execute(&state.app_ctx.deps.wrapup.generate, cmd).await?;
|
||||
Ok(Json(WrapUpGeneratedResponse {
|
||||
id: id.value().to_string(),
|
||||
}))
|
||||
@@ -90,7 +85,7 @@ pub async fn get_list(
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<WrapUpListResponse>, ApiError> {
|
||||
let records = list_wrapups::execute(
|
||||
state.app_ctx.repos.wrapup_repo.clone(),
|
||||
&state.app_ctx.deps.wrapup.list_wrapups,
|
||||
ListWrapUpsQuery {
|
||||
user_id: Some(user.0.value()),
|
||||
},
|
||||
@@ -117,7 +112,7 @@ pub async fn get_status(
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<WrapUpStatusResponse>, ApiError> {
|
||||
let record = get_wrapup::execute(
|
||||
state.app_ctx.repos.wrapup_repo.clone(),
|
||||
&state.app_ctx.deps.wrapup.get_wrapup,
|
||||
WrapUpId::from_uuid(id),
|
||||
)
|
||||
.await?
|
||||
@@ -142,7 +137,7 @@ pub async fn get_report(
|
||||
Path(id): Path<Uuid>,
|
||||
) -> impl IntoResponse {
|
||||
match get_wrapup::execute(
|
||||
state.app_ctx.repos.wrapup_repo.clone(),
|
||||
&state.app_ctx.deps.wrapup.get_wrapup,
|
||||
WrapUpId::from_uuid(id),
|
||||
)
|
||||
.await
|
||||
@@ -177,7 +172,7 @@ pub async fn delete_wrapup_handler(
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
delete_wrapup::execute(
|
||||
state.app_ctx.repos.wrapup_repo.clone(),
|
||||
&state.app_ctx.deps.wrapup.delete_wrapup,
|
||||
WrapUpId::from_uuid(id),
|
||||
)
|
||||
.await?;
|
||||
@@ -243,30 +238,15 @@ pub async fn get_user_wrapup_html(
|
||||
Path((user_id, year)): Path<(Uuid, i32)>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let start = match NaiveDate::from_ymd_opt(year, 1, 1) {
|
||||
Some(d) => d,
|
||||
None => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
let end = match NaiveDate::from_ymd_opt(year + 1, 1, 1) {
|
||||
Some(d) => d,
|
||||
None => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
|
||||
let record = match state
|
||||
.app_ctx
|
||||
.repos
|
||||
.wrapup_repo
|
||||
.find_existing(Some(user_id), start, end)
|
||||
.await
|
||||
{
|
||||
Ok(Some(r)) if r.status == WrapUpStatus::Ready => r,
|
||||
_ => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
|
||||
let report = match record.report {
|
||||
Some(r) => r,
|
||||
None => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
let scope = WrapUpScope::User(user_id);
|
||||
let report =
|
||||
match get_ready_report::execute(&state.app_ctx.deps.wrapup.get_ready_report, scope, year)
|
||||
.await
|
||||
{
|
||||
Ok(report) => report,
|
||||
Err(DomainError::ValidationError(_)) => return StatusCode::BAD_REQUEST.into_response(),
|
||||
Err(_) => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
|
||||
let ctx = super::helpers::build_page_context(&state, viewer, csrf.0).await;
|
||||
render_wrapup(&report, year, &ctx)
|
||||
@@ -278,29 +258,16 @@ pub async fn get_global_wrapup_html(
|
||||
Path(year): Path<i32>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let start = match NaiveDate::from_ymd_opt(year, 1, 1) {
|
||||
Some(d) => d,
|
||||
None => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
let end = match NaiveDate::from_ymd_opt(year + 1, 1, 1) {
|
||||
Some(d) => d,
|
||||
None => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
|
||||
let record = match state
|
||||
.app_ctx
|
||||
.repos
|
||||
.wrapup_repo
|
||||
.find_existing(None, start, end)
|
||||
.await
|
||||
let report = match get_ready_report::execute(
|
||||
&state.app_ctx.deps.wrapup.get_ready_report,
|
||||
WrapUpScope::Global,
|
||||
year,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(r)) if r.status == WrapUpStatus::Ready => r,
|
||||
_ => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
|
||||
let report = match record.report {
|
||||
Some(r) => r,
|
||||
None => return StatusCode::NOT_FOUND.into_response(),
|
||||
Ok(report) => report,
|
||||
Err(DomainError::ValidationError(_)) => return StatusCode::BAD_REQUEST.into_response(),
|
||||
Err(_) => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
|
||||
let ctx = super::helpers::build_page_context(&state, viewer, csrf.0).await;
|
||||
|
||||
@@ -2,7 +2,6 @@ pub mod context;
|
||||
pub mod csrf;
|
||||
pub mod errors;
|
||||
pub mod extractors;
|
||||
pub mod factory;
|
||||
pub mod forms;
|
||||
pub mod handlers;
|
||||
pub mod mappers;
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
|
||||
use tokio::net::TcpListener;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use application::config::AppConfig;
|
||||
use export::ExportAdapter;
|
||||
use importer::ImporterDocumentParser;
|
||||
use presentation::context::{AppContext, Repositories, Services};
|
||||
use presentation::{factory, openapi, routes, state::AppState};
|
||||
use rss::RssAdapter;
|
||||
|
||||
use domain::ports::{DiaryExporter, DocumentParser, EventPublisher};
|
||||
use infra_wiring::EventBusBackend;
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
use postgres_search;
|
||||
|
||||
#[cfg(not(any(feature = "sqlite", feature = "postgres")))]
|
||||
compile_error!(
|
||||
"At least one database backend must be enabled. Use --features sqlite or --features postgres"
|
||||
);
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
dotenvy::dotenv().ok();
|
||||
init_tracing();
|
||||
|
||||
let (state, ap_router) = wire_dependencies()
|
||||
.await
|
||||
.context("Failed to wire dependencies")?;
|
||||
|
||||
let app = openapi::serve(routes::build_router(state, ap_router));
|
||||
|
||||
let host = std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
|
||||
let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string());
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let listener = TcpListener::bind(&addr).await?;
|
||||
tracing::info!("Listening on {}", addr);
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
let app_config = AppConfig::from_env();
|
||||
let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
|
||||
let backend = std::env::var("DATABASE_BACKEND").unwrap_or_else(|_| "sqlite".to_string());
|
||||
|
||||
let (auth_service, password_hasher) = factory::build_auth_adapters()?;
|
||||
let metadata_client = factory::build_metadata_client()?;
|
||||
let poster_fetcher = factory::build_poster_fetcher()?;
|
||||
let object_storage = factory::build_object_storage()?;
|
||||
|
||||
let db = factory::build_database_adapters(&backend, &database_url).await?;
|
||||
let ap_content_repo = db.ap_content;
|
||||
let db_pool = db.db_pool;
|
||||
|
||||
// Wire up event channel, federation service, and ap_router
|
||||
let event_bus = EventBusBackend::from_env()?;
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
let (
|
||||
event_publisher_arc,
|
||||
ap_router,
|
||||
ap_service,
|
||||
social_query,
|
||||
remote_watchlist_repo,
|
||||
social_command_arc,
|
||||
social_query_unified_arc,
|
||||
) = {
|
||||
let fed_repos = match &db_pool {
|
||||
#[cfg(feature = "postgres-federation")]
|
||||
factory::DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()),
|
||||
#[cfg(feature = "sqlite-federation")]
|
||||
factory::DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()),
|
||||
#[cfg(not(feature = "sqlite-federation"))]
|
||||
_ => anyhow::bail!(
|
||||
"DATABASE_BACKEND={backend} federation is not supported by this build"
|
||||
),
|
||||
};
|
||||
|
||||
let ep = create_event_publisher(event_bus, &db_pool).await?;
|
||||
|
||||
let ap = activitypub::wire(activitypub::ActivityPubDeps {
|
||||
activity_repo: fed_repos.activity,
|
||||
follow_repo: fed_repos.follow,
|
||||
actor_repo: fed_repos.actor,
|
||||
blocklist_repo: fed_repos.blocklist,
|
||||
review_store: fed_repos.review_store,
|
||||
remote_watchlist_repo: fed_repos.remote_watchlist.clone(),
|
||||
remote_goal_repo: Arc::clone(&db.remote_goal),
|
||||
local_ap_content: Arc::clone(&ap_content_repo),
|
||||
movie_repo: Arc::clone(&db.movie_query),
|
||||
review_repo: Arc::clone(&db.review),
|
||||
diary_repo: Arc::clone(&db.diary),
|
||||
goal_repo: Arc::clone(&db.goal_query),
|
||||
stats_repo: Arc::clone(&db.stats),
|
||||
user_repo: Arc::clone(&db.user),
|
||||
federation_settings: std::sync::Arc::clone(&db.federation_settings),
|
||||
follow_command: Arc::clone(&fed_repos.follow_command),
|
||||
follow_query: Arc::clone(&fed_repos.follow_query),
|
||||
base_url: app_config.base_url.clone(),
|
||||
allow_registration: app_config.allow_registration,
|
||||
event_publisher: Arc::clone(&ep),
|
||||
})
|
||||
.await?;
|
||||
let ap_router = ap.router;
|
||||
let ap_service_arc = ap.service;
|
||||
|
||||
let composite_social = Arc::new(activitypub::CompositeSocialAdapter::new(
|
||||
Arc::clone(&ap_service_arc),
|
||||
Arc::clone(&db.user),
|
||||
fed_repos.follow_command,
|
||||
fed_repos.follow_query,
|
||||
app_config.base_url.clone(),
|
||||
));
|
||||
|
||||
(
|
||||
ep,
|
||||
ap_router,
|
||||
ap_service_arc,
|
||||
fed_repos.admin_query,
|
||||
fed_repos.remote_watchlist,
|
||||
composite_social.clone() as Arc<dyn domain::ports::SocialCommand>,
|
||||
composite_social as Arc<dyn domain::ports::SocialQuery>,
|
||||
)
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "federation"))]
|
||||
let event_publisher_arc = create_event_publisher(event_bus, &db_pool).await?;
|
||||
#[cfg(not(feature = "federation"))]
|
||||
let ap_router = axum::Router::new();
|
||||
#[cfg(not(feature = "federation"))]
|
||||
let social_command_arc: Arc<dyn domain::ports::SocialCommand> =
|
||||
Arc::new(domain::ports::noop::NoopSocialCommand);
|
||||
#[cfg(not(feature = "federation"))]
|
||||
let social_query_unified_arc: Arc<dyn domain::ports::SocialQuery> =
|
||||
Arc::new(domain::ports::noop::NoopSocialQuery);
|
||||
|
||||
let review_logger = Arc::new(application::diary::review_logger::DefaultReviewLogger::new(
|
||||
Arc::clone(&db.movie_command),
|
||||
Arc::clone(&db.movie_query),
|
||||
Arc::clone(&db.review),
|
||||
Arc::clone(&db.watchlist),
|
||||
Arc::clone(&metadata_client),
|
||||
Arc::clone(&event_publisher_arc),
|
||||
));
|
||||
|
||||
let app_ctx = AppContext {
|
||||
repos: Repositories {
|
||||
movie_command: db.movie_command,
|
||||
movie_query: db.movie_query,
|
||||
review: db.review,
|
||||
diary: db.diary,
|
||||
stats: db.stats,
|
||||
user: db.user,
|
||||
import_session: db.import_session,
|
||||
import_profile: db.import_profile,
|
||||
movie_profile: db.movie_profile,
|
||||
watchlist: db.watchlist,
|
||||
watch_event_command: db.watch_event_command,
|
||||
watch_event_query: db.watch_event_query,
|
||||
webhook_token: db.webhook_token,
|
||||
person_command: db.person_command,
|
||||
person_query: db.person_query,
|
||||
search_port: db.search_port,
|
||||
search_command: db.search_command,
|
||||
profile_fields: db.profile_fields,
|
||||
#[cfg(feature = "federation")]
|
||||
remote_watchlist: remote_watchlist_repo,
|
||||
#[cfg(not(feature = "federation"))]
|
||||
remote_watchlist: Arc::new(domain::ports::noop::NoopRemoteWatchlistRepository),
|
||||
social_command: social_command_arc,
|
||||
social_query_unified: social_query_unified_arc,
|
||||
#[cfg(feature = "federation")]
|
||||
federation_admin: social_query.clone(),
|
||||
#[cfg(not(feature = "federation"))]
|
||||
federation_admin: Arc::new(domain::ports::noop::NoopFederationAdminQuery),
|
||||
wrapup_stats: db.wrapup_stats,
|
||||
wrapup_repo: db.wrapup_repo,
|
||||
goal_command: db.goal_command,
|
||||
goal_query: db.goal_query,
|
||||
user_settings: db.user_settings,
|
||||
remote_goal: db.remote_goal,
|
||||
refresh_session: db.refresh_session,
|
||||
#[cfg(feature = "federation")]
|
||||
federated_profile: Some({
|
||||
match &db_pool {
|
||||
#[cfg(feature = "sqlite-federation")]
|
||||
factory::DbPool::Sqlite(pool) => {
|
||||
sqlite_federation::create_federated_profile_query(pool.clone())
|
||||
}
|
||||
#[cfg(feature = "postgres-federation")]
|
||||
factory::DbPool::Postgres(pool) => {
|
||||
postgres_federation::create_federated_profile_query(pool.clone())
|
||||
}
|
||||
#[cfg(not(feature = "sqlite-federation"))]
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}),
|
||||
#[cfg(not(feature = "federation"))]
|
||||
federated_profile: None,
|
||||
},
|
||||
services: Services {
|
||||
auth: auth_service,
|
||||
password_hasher,
|
||||
metadata: metadata_client,
|
||||
poster_fetcher,
|
||||
object_storage,
|
||||
event_publisher: event_publisher_arc,
|
||||
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
|
||||
document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>,
|
||||
review_logger,
|
||||
person_enrichment: None,
|
||||
#[cfg(feature = "federation")]
|
||||
ap_service,
|
||||
},
|
||||
config: app_config,
|
||||
};
|
||||
|
||||
let state = AppState {
|
||||
app_ctx,
|
||||
rss_renderer: Arc::new(RssAdapter::new(
|
||||
std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()),
|
||||
)),
|
||||
};
|
||||
Ok((state, ap_router))
|
||||
}
|
||||
|
||||
async fn create_event_publisher(
|
||||
event_bus: EventBusBackend,
|
||||
db_pool: &factory::DbPool,
|
||||
) -> anyhow::Result<Arc<dyn EventPublisher>> {
|
||||
match event_bus {
|
||||
EventBusBackend::Db => {
|
||||
tracing::info!("event bus: DB queue");
|
||||
Ok(match db_pool {
|
||||
#[cfg(feature = "postgres")]
|
||||
factory::DbPool::Postgres(pool) => {
|
||||
postgres_event_queue::PostgresEventQueue::create_publisher(pool.clone()).await?
|
||||
}
|
||||
#[cfg(feature = "sqlite")]
|
||||
factory::DbPool::Sqlite(pool) => {
|
||||
sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()).await?
|
||||
}
|
||||
})
|
||||
}
|
||||
#[cfg(feature = "nats")]
|
||||
EventBusBackend::Nats => {
|
||||
let cfg = nats::NatsConfig::from_env()
|
||||
.context("EVENT_BUS_BACKEND=nats requires NATS_URL to be set")?;
|
||||
tracing::info!("event bus: NATS ({})", cfg.url);
|
||||
Ok(nats::create_publisher(cfg).await?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::EnvFilter::new(
|
||||
std::env::var("RUST_LOG")
|
||||
.unwrap_or_else(|_| "presentation=debug,tower_http=debug".into()),
|
||||
))
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
}
|
||||
@@ -3,7 +3,5 @@ pub mod import;
|
||||
pub mod integrations;
|
||||
pub mod movies;
|
||||
pub mod search;
|
||||
#[cfg(feature = "federation")]
|
||||
pub mod social;
|
||||
pub mod users;
|
||||
pub mod watchlist;
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
use api_types::RemoteActorDto;
|
||||
|
||||
pub fn remote_actor_to_dto(a: activitypub::RemoteActor) -> RemoteActorDto {
|
||||
RemoteActorDto {
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
url: a.url,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use application::users::get_profile::PendingFollowerView;
|
||||
use application::users::get_local_profile::PendingFollowerView;
|
||||
use domain::models::RemoteActorInfo;
|
||||
use domain::models::UserSummary;
|
||||
use template_askama::{RemoteActorData, RemoteActorDisplay, UserSummaryView};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#[cfg(feature = "federation")]
|
||||
use api_types::{
|
||||
ActorListResponse, ActorUrlRequest, AddBlockedDomainRequest, BlockedActorResponse,
|
||||
BlockedDomainResponse, FollowRequest, RemoteActorDto,
|
||||
BlockedDomainResponse, FollowRelationResponse, FollowRequest, PendingCountResponse,
|
||||
RemoteActorDto,
|
||||
};
|
||||
#[cfg(feature = "federation")]
|
||||
use utoipa::OpenApi;
|
||||
@@ -13,6 +14,9 @@ use utoipa::OpenApi;
|
||||
crate::handlers::social::get_following,
|
||||
crate::handlers::social::get_followers,
|
||||
crate::handlers::social::get_pending_followers,
|
||||
crate::handlers::social::get_pending_follower_count,
|
||||
crate::handlers::social::get_pending_following,
|
||||
crate::handlers::social::get_relationship,
|
||||
crate::handlers::social::follow,
|
||||
crate::handlers::social::unfollow,
|
||||
crate::handlers::social::accept_follower,
|
||||
@@ -33,6 +37,8 @@ use utoipa::OpenApi;
|
||||
BlockedDomainResponse,
|
||||
AddBlockedDomainRequest,
|
||||
BlockedActorResponse,
|
||||
FollowRelationResponse,
|
||||
PendingCountResponse,
|
||||
))
|
||||
)]
|
||||
pub struct SocialDoc;
|
||||
|
||||
@@ -184,22 +184,22 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
|
||||
routing::get(handlers::wrapup::get_global_wrapup_html),
|
||||
);
|
||||
|
||||
let base = base.merge(social_html_routes());
|
||||
#[cfg(feature = "federation")]
|
||||
let base = base.merge(federation_html_routes());
|
||||
|
||||
base.layer(axum::middleware::from_fn(crate::csrf::csrf_middleware))
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
fn federation_html_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
fn social_html_routes() -> Router<AppState> {
|
||||
let base = Router::new()
|
||||
.route(
|
||||
"/users/{id}/follow",
|
||||
routing::post(handlers::social::follow_remote_user),
|
||||
routing::post(handlers::social::follow_user),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/unfollow",
|
||||
routing::post(handlers::social::unfollow_remote_user),
|
||||
routing::post(handlers::social::unfollow_user),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/followers/accept",
|
||||
@@ -210,12 +210,8 @@ fn federation_html_routes() -> Router<AppState> {
|
||||
routing::post(handlers::social::reject_follower_html),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/followers",
|
||||
routing::get(handlers::social::get_followers_collection),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/following",
|
||||
routing::get(handlers::social::get_following_collection),
|
||||
"/users/{id}/followers/remove",
|
||||
routing::post(handlers::social::remove_follower_html),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/following-list",
|
||||
@@ -224,10 +220,40 @@ fn federation_html_routes() -> Router<AppState> {
|
||||
.route(
|
||||
"/users/{id}/followers-list",
|
||||
routing::get(handlers::social::get_followers_page),
|
||||
);
|
||||
|
||||
#[cfg(not(feature = "federation"))]
|
||||
let base = base
|
||||
.route(
|
||||
"/users/{id}/followers",
|
||||
routing::get(
|
||||
|axum::extract::Path(id): axum::extract::Path<uuid::Uuid>| async move {
|
||||
axum::response::Redirect::permanent(&format!("/users/{}/followers-list", id))
|
||||
},
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/followers/remove",
|
||||
routing::post(handlers::social::remove_follower_html),
|
||||
"/users/{id}/following",
|
||||
routing::get(
|
||||
|axum::extract::Path(id): axum::extract::Path<uuid::Uuid>| async move {
|
||||
axum::response::Redirect::permanent(&format!("/users/{}/following-list", id))
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
base
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
fn federation_html_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/users/{id}/followers",
|
||||
routing::get(handlers::social::get_followers_collection),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/following",
|
||||
routing::get(handlers::social::get_following_collection),
|
||||
)
|
||||
.route(
|
||||
"/admin/blocked-domains",
|
||||
@@ -455,6 +481,7 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
|
||||
routing::get(handlers::goals::get_settings).put(handlers::goals::update_settings),
|
||||
);
|
||||
|
||||
let base = base.merge(social_api_routes());
|
||||
#[cfg(feature = "federation")]
|
||||
let base = base.merge(federation_api_routes());
|
||||
|
||||
@@ -481,8 +508,7 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
|
||||
.layer(cors_layer())
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
fn federation_api_routes() -> Router<AppState> {
|
||||
fn social_api_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/social/following",
|
||||
@@ -496,6 +522,18 @@ fn federation_api_routes() -> Router<AppState> {
|
||||
"/social/followers/pending",
|
||||
routing::get(handlers::social::get_pending_followers),
|
||||
)
|
||||
.route(
|
||||
"/social/followers/pending/count",
|
||||
routing::get(handlers::social::get_pending_follower_count),
|
||||
)
|
||||
.route(
|
||||
"/social/following/pending",
|
||||
routing::get(handlers::social::get_pending_following),
|
||||
)
|
||||
.route(
|
||||
"/social/relationship",
|
||||
routing::get(handlers::social::get_relationship),
|
||||
)
|
||||
.route("/social/follow", routing::post(handlers::social::follow))
|
||||
.route(
|
||||
"/social/unfollow",
|
||||
@@ -513,6 +551,19 @@ fn federation_api_routes() -> Router<AppState> {
|
||||
"/social/followers/remove",
|
||||
routing::post(handlers::social::remove_follower),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/following",
|
||||
routing::get(handlers::social::get_user_following),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/followers",
|
||||
routing::get(handlers::social::get_user_followers),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
fn federation_api_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/admin/blocked-domains",
|
||||
routing::get(handlers::social::get_blocked_domains_admin)
|
||||
@@ -534,12 +585,4 @@ fn federation_api_routes() -> Router<AppState> {
|
||||
"/social/blocked",
|
||||
routing::get(handlers::social::get_blocked_actors_api),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/following",
|
||||
routing::get(handlers::social::get_user_following),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/followers",
|
||||
routing::get(handlers::social::get_user_followers),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::extractors::tests::{Panic, make_test_state};
|
||||
use crate::extractors::tests::{AcceptingAuth, test_app_state, test_app_state_with_follow_graph};
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
@@ -73,9 +73,7 @@ impl domain::ports::PersonQuery for PersonQueryStub {
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_endpoint_returns_200_with_empty_results() {
|
||||
let mut state = make_test_state(Arc::new(Panic));
|
||||
// Override the search_port with our stub
|
||||
state.app_ctx.repos.search_port = Arc::new(SearchPortStub);
|
||||
let state = crate::extractors::tests::test_app_state_with_search_port(Arc::new(SearchPortStub));
|
||||
let app = Router::new()
|
||||
.route("/api/v1/search", get(crate::handlers::search::get_search))
|
||||
.with_state(state);
|
||||
@@ -95,9 +93,7 @@ async fn search_endpoint_returns_200_with_empty_results() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_endpoint_with_no_query_returns_200() {
|
||||
let mut state = make_test_state(Arc::new(Panic));
|
||||
// Override the search_port with our stub
|
||||
state.app_ctx.repos.search_port = Arc::new(SearchPortStub);
|
||||
let state = crate::extractors::tests::test_app_state_with_search_port(Arc::new(SearchPortStub));
|
||||
let app = Router::new()
|
||||
.route("/api/v1/search", get(crate::handlers::search::get_search))
|
||||
.with_state(state);
|
||||
@@ -119,9 +115,8 @@ async fn search_endpoint_with_no_query_returns_200() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn person_endpoint_returns_404_for_unknown_id() {
|
||||
let mut state = make_test_state(Arc::new(Panic));
|
||||
// Override the person_query with our stub
|
||||
state.app_ctx.repos.person_query = Arc::new(PersonQueryStub);
|
||||
let state =
|
||||
crate::extractors::tests::test_app_state_with_person_query(Arc::new(PersonQueryStub));
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/v1/people/{id}",
|
||||
@@ -145,9 +140,8 @@ async fn person_endpoint_returns_404_for_unknown_id() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn person_credits_endpoint_returns_404_for_unknown_id() {
|
||||
let mut state = make_test_state(Arc::new(Panic));
|
||||
// Override the person_query with our stub
|
||||
state.app_ctx.repos.person_query = Arc::new(PersonQueryStub);
|
||||
let state =
|
||||
crate::extractors::tests::test_app_state_with_person_query(Arc::new(PersonQueryStub));
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/v1/people/{id}/credits",
|
||||
@@ -169,11 +163,30 @@ async fn person_credits_endpoint_returns_404_for_unknown_id() {
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
/// Proves stub injection reaches the prebuilt deps, not just the `Repositories`
|
||||
/// passed into `build_test_state_from`. Before the fix that added construct-time
|
||||
/// stub injection, this failed on the assertion below — exactly the false-green
|
||||
/// that would have silently broken the person-handler tests in Task 6. `AppContext`
|
||||
/// no longer carries a `repos` field (Plan C2), so the original companion
|
||||
/// assertion on `state.app_ctx.repos.person_query` was dropped along with the
|
||||
/// field; the assertion that matters — that `deps` itself holds the stub — stays.
|
||||
#[test]
|
||||
fn injected_stub_reaches_prebuilt_deps() {
|
||||
let stub: Arc<dyn domain::ports::PersonQuery> = Arc::new(PersonQueryStub);
|
||||
let state = crate::extractors::tests::test_app_state_with_person_query(Arc::clone(&stub));
|
||||
|
||||
assert!(
|
||||
Arc::ptr_eq(&state.app_ctx.deps.person.get_person.person_query, &stub),
|
||||
"prebuilt deps must hold the injected stub too, or handlers reading deps \
|
||||
will silently use the real dependency"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Watchlist endpoint tests ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_watchlist_requires_auth() {
|
||||
let state = make_test_state(Arc::new(Panic));
|
||||
let state = test_app_state();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/v1/watchlist",
|
||||
@@ -194,9 +207,103 @@ async fn get_watchlist_requires_auth() {
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// --- Pending-follower-count endpoint tests ---
|
||||
|
||||
/// Stub `FollowGraphQuery` returning a fixed pending-follower count.
|
||||
struct FixedPendingCount(usize);
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::FollowGraphQuery for FixedPendingCount {
|
||||
async fn get_following(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn get_followers(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn get_pending_following(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn count_following(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<usize, DomainError> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn count_followers(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<usize, DomainError> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn count_pending_followers(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
) -> Result<usize, DomainError> {
|
||||
Ok(self.0)
|
||||
}
|
||||
async fn get_relation(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
_: &domain::value_objects::SocialIdentity,
|
||||
) -> Result<domain::value_objects::FollowRelation, DomainError> {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
/// The point of Task 4: one use case (`social::count_pending_followers`) now
|
||||
/// serves both the classic UI (via `get_page_viewer`) and this new HTTP
|
||||
/// endpoint. Seed the query port with a fixed count and assert the endpoint
|
||||
/// reflects it verbatim.
|
||||
#[tokio::test]
|
||||
async fn pending_count_endpoint_reports_the_use_case_result() {
|
||||
let uid = domain::value_objects::UserId::from_uuid(Uuid::new_v4());
|
||||
let state = test_app_state_with_follow_graph(
|
||||
Arc::new(FixedPendingCount(3)),
|
||||
Arc::new(AcceptingAuth(uid)),
|
||||
);
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/v1/social/followers/pending/count",
|
||||
get(crate::handlers::social::get_pending_follower_count),
|
||||
)
|
||||
.with_state(state);
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/social/followers/pending/count")
|
||||
.header("authorization", "Bearer test-token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["count"], 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_watchlist_status_requires_auth() {
|
||||
let state = make_test_state(Arc::new(Panic));
|
||||
let state = test_app_state();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/v1/watchlist/{movie_id}",
|
||||
@@ -216,3 +323,88 @@ async fn get_watchlist_status_requires_auth() {
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// --- Federation collection error-handling tests ---
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
struct FailingApDocument;
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::ApDocumentPort for FailingApDocument {
|
||||
async fn actor_json(&self, _: &str) -> Result<String, DomainError> {
|
||||
Err(DomainError::InfrastructureError("boom".into()))
|
||||
}
|
||||
async fn followers_collection_json(
|
||||
&self,
|
||||
_: Uuid,
|
||||
_: Option<u32>,
|
||||
) -> Result<String, DomainError> {
|
||||
Err(DomainError::InfrastructureError("boom".into()))
|
||||
}
|
||||
async fn following_collection_json(
|
||||
&self,
|
||||
_: Uuid,
|
||||
_: Option<u32>,
|
||||
) -> Result<String, DomainError> {
|
||||
Err(DomainError::InfrastructureError("boom".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Swapping `ap_document` on the returned state is safe — unlike the `repos`
|
||||
/// fields, handlers read `app_ctx.ap_document` directly and no prebuilt deps
|
||||
/// struct carries a copy of it.
|
||||
#[cfg(feature = "federation")]
|
||||
fn state_with_failing_ap_document() -> crate::state::AppState {
|
||||
let mut state = test_app_state();
|
||||
state.app_ctx.ap_document = Arc::new(FailingApDocument);
|
||||
state
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
#[tokio::test]
|
||||
async fn followers_collection_returns_500_on_federation_error() {
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/users/{id}/followers",
|
||||
get(crate::handlers::social::get_followers_collection),
|
||||
)
|
||||
.with_state(state_with_failing_ap_document());
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/users/{}/followers", Uuid::nil()))
|
||||
.header(axum::http::header::ACCEPT, "application/activity+json")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
#[tokio::test]
|
||||
async fn following_collection_returns_500_on_federation_error() {
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/users/{id}/following",
|
||||
get(crate::handlers::social::get_following_collection),
|
||||
)
|
||||
.with_state(state_with_failing_ap_document());
|
||||
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/users/{}/following", Uuid::nil()))
|
||||
.header(axum::http::header::ACCEPT, "application/activity+json")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
35
crates/presentation/src/tests/context.rs
Normal file
35
crates/presentation/src/tests/context.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
/// The prebuilt deps must point at the SAME port instances as the `Repositories`
|
||||
/// they were built from. If build_deps is wired to a different Arc — or is called
|
||||
/// before a repo is swapped — handlers in Plan B silently talk to the wrong
|
||||
/// dependency, and no other test in Plan A would catch it.
|
||||
///
|
||||
/// `AppContext` no longer carries a `repos` field (Plan C2 deleted it), so this
|
||||
/// test builds its own `Repositories` locally, keeps a clone of the Arcs it
|
||||
/// cares about, and hands the original to `build_test_state_from` — the same
|
||||
/// construction `test_app_state()` uses internally.
|
||||
#[test]
|
||||
fn prebuilt_deps_share_port_instances_with_repos() {
|
||||
use std::sync::Arc;
|
||||
|
||||
let repos = crate::extractors::tests::test_repositories();
|
||||
let user = Arc::clone(&repos.user);
|
||||
let follow_graph = Arc::clone(&repos.follow_graph);
|
||||
|
||||
let state = crate::extractors::tests::build_test_state_from(
|
||||
repos,
|
||||
Arc::new(crate::extractors::tests::Panic),
|
||||
);
|
||||
|
||||
assert!(
|
||||
Arc::ptr_eq(&state.app_ctx.deps.users.get_local_profile.user, &user),
|
||||
"deps.users.get_local_profile.user must be the same Arc as the Repositories it was built from"
|
||||
);
|
||||
assert!(
|
||||
Arc::ptr_eq(&state.app_ctx.deps.social.query.follow_graph, &follow_graph),
|
||||
"deps.social.query.follow_graph must be the same Arc as the Repositories it was built from"
|
||||
);
|
||||
assert_eq!(
|
||||
state.app_ctx.deps.users.get_local_profile.instance, state.app_ctx.instance,
|
||||
"prebuilt deps must carry the context's InstanceIdentity"
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::*;
|
||||
use crate::context::{AppContext, Repositories, Services};
|
||||
use crate::context::AppContext;
|
||||
use application::config::AppConfig;
|
||||
use axum::{
|
||||
Router,
|
||||
@@ -7,6 +7,7 @@ use axum::{
|
||||
http::{Request, StatusCode},
|
||||
routing::get,
|
||||
};
|
||||
use composition::Repositories;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
@@ -17,11 +18,12 @@ use domain::{
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
ports::{
|
||||
AuthService, DiaryQuery, EventPublisher, MetadataClient, MovieCommand, MovieQuery,
|
||||
ObjectStorage, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient,
|
||||
AuthService, DiaryQuery, EventPublisher, FollowGraphQuery, MetadataClient, MovieCommand,
|
||||
MovieQuery, ObjectStorage, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient,
|
||||
ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserRepository,
|
||||
WatchlistRepository,
|
||||
},
|
||||
testing::InMemoryUserRepository,
|
||||
value_objects::{
|
||||
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl, ReleaseYear,
|
||||
ReviewId, UserId,
|
||||
@@ -761,74 +763,232 @@ impl application::ports::ReviewLogger for Panic {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Single state factory — only auth_service varies ---
|
||||
// --- Repositories factory — `Panic` stubs everywhere ---
|
||||
|
||||
pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppState {
|
||||
pub(crate) fn test_repositories() -> Repositories {
|
||||
let repo = Arc::new(Panic);
|
||||
|
||||
Repositories {
|
||||
movie_command: Arc::clone(&repo) as _,
|
||||
movie_query: Arc::clone(&repo) as _,
|
||||
review: Arc::clone(&repo) as _,
|
||||
diary: Arc::clone(&repo) as _,
|
||||
stats: Arc::clone(&repo) as _,
|
||||
user: Arc::clone(&repo) as _,
|
||||
import_session: Arc::clone(&repo) as _,
|
||||
import_profile: Arc::clone(&repo) as _,
|
||||
movie_profile: Arc::clone(&repo) as _,
|
||||
watchlist: Arc::clone(&repo) as _,
|
||||
watch_event_command: Arc::clone(&repo) as _,
|
||||
watch_event_query: Arc::clone(&repo) as _,
|
||||
webhook_token: Arc::clone(&repo) as _,
|
||||
profile_fields: Arc::clone(&repo) as _,
|
||||
person_command: Arc::clone(&repo) as _,
|
||||
person_query: Arc::clone(&repo) as _,
|
||||
search_port: Arc::clone(&repo) as _,
|
||||
search_command: Arc::clone(&repo) as _,
|
||||
remote_watchlist: Arc::clone(&repo) as _,
|
||||
social_command: Arc::new(domain::ports::noop::NoopSocialCommand) as _,
|
||||
follow_graph: Arc::new(domain::ports::noop::NoopSocialQuery) as _,
|
||||
block_query: Arc::new(domain::ports::noop::NoopSocialQuery) as _,
|
||||
federation_admin: Arc::new(domain::ports::noop::NoopFederationAdminQuery) as _,
|
||||
wrapup_stats: Arc::clone(&repo) as _,
|
||||
wrapup_repo: Arc::clone(&repo) as _,
|
||||
goal_command: Arc::clone(&repo) as _,
|
||||
goal_query: Arc::clone(&repo) as _,
|
||||
user_settings: Arc::clone(&repo) as _,
|
||||
remote_goal: Arc::clone(&repo) as _,
|
||||
refresh_session: Arc::clone(&repo) as _,
|
||||
federated_profile: None,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Single state factory — builds `deps` from whatever `repos` it is given ---
|
||||
|
||||
/// Builds an `AppState` from caller-supplied `repos`, running `composition::build_deps`
|
||||
/// AFTER any overrides the caller already applied to `repos`. This is the only place
|
||||
/// `deps` gets built for tests, so `repos` and `deps` can never disagree.
|
||||
pub(crate) fn build_test_state_from(
|
||||
repos: Repositories,
|
||||
auth_service: Arc<dyn AuthService>,
|
||||
) -> crate::state::AppState {
|
||||
let repo = Arc::new(Panic);
|
||||
|
||||
let services = application::Services {
|
||||
auth: auth_service,
|
||||
password_hasher: Arc::clone(&repo) as _,
|
||||
metadata: Arc::clone(&repo) as _,
|
||||
poster_fetcher: Arc::clone(&repo) as _,
|
||||
object_storage: Arc::clone(&repo) as _,
|
||||
event_publisher: Arc::clone(&repo) as _,
|
||||
diary_exporter: Arc::clone(&repo) as _,
|
||||
document_parser: Arc::clone(&repo) as _,
|
||||
review_logger: Arc::clone(&repo) as _,
|
||||
person_enrichment: None,
|
||||
};
|
||||
|
||||
let config = AppConfig {
|
||||
allow_registration: false,
|
||||
base_url: "http://localhost:3000".to_string(),
|
||||
rate_limit: 20,
|
||||
refresh_ttl_seconds: 2_592_000,
|
||||
wrapup: application::config::WrapUpConfig {
|
||||
font_path: None,
|
||||
logo_path: None,
|
||||
bg_dir: None,
|
||||
},
|
||||
};
|
||||
|
||||
let instance = domain::value_objects::InstanceIdentity::new("http://localhost:3000");
|
||||
|
||||
let deps = Arc::new(composition::build_deps(
|
||||
&repos, &services, &config, &instance,
|
||||
));
|
||||
|
||||
crate::state::AppState {
|
||||
app_ctx: AppContext {
|
||||
repos: Repositories {
|
||||
movie_command: Arc::clone(&repo) as _,
|
||||
movie_query: Arc::clone(&repo) as _,
|
||||
review: Arc::clone(&repo) as _,
|
||||
diary: Arc::clone(&repo) as _,
|
||||
stats: Arc::clone(&repo) as _,
|
||||
user: Arc::clone(&repo) as _,
|
||||
import_session: Arc::clone(&repo) as _,
|
||||
import_profile: Arc::clone(&repo) as _,
|
||||
movie_profile: Arc::clone(&repo) as _,
|
||||
watchlist: Arc::clone(&repo) as _,
|
||||
watch_event_command: Arc::clone(&repo) as _,
|
||||
watch_event_query: Arc::clone(&repo) as _,
|
||||
webhook_token: Arc::clone(&repo) as _,
|
||||
profile_fields: Arc::clone(&repo) as _,
|
||||
person_command: Arc::clone(&repo) as _,
|
||||
person_query: Arc::clone(&repo) as _,
|
||||
search_port: Arc::clone(&repo) as _,
|
||||
search_command: Arc::clone(&repo) as _,
|
||||
remote_watchlist: Arc::clone(&repo) as _,
|
||||
social_command: Arc::new(domain::ports::noop::NoopSocialCommand) as _,
|
||||
social_query_unified: Arc::new(domain::ports::noop::NoopSocialQuery) as _,
|
||||
federation_admin: Arc::new(domain::ports::noop::NoopFederationAdminQuery) as _,
|
||||
wrapup_stats: Arc::clone(&repo) as _,
|
||||
wrapup_repo: Arc::clone(&repo) as _,
|
||||
goal_command: Arc::clone(&repo) as _,
|
||||
goal_query: Arc::clone(&repo) as _,
|
||||
user_settings: Arc::clone(&repo) as _,
|
||||
remote_goal: Arc::clone(&repo) as _,
|
||||
refresh_session: Arc::clone(&repo) as _,
|
||||
federated_profile: None,
|
||||
},
|
||||
services: Services {
|
||||
auth: auth_service,
|
||||
password_hasher: Arc::clone(&repo) as _,
|
||||
metadata: Arc::clone(&repo) as _,
|
||||
poster_fetcher: Arc::clone(&repo) as _,
|
||||
object_storage: Arc::clone(&repo) as _,
|
||||
event_publisher: Arc::clone(&repo) as _,
|
||||
diary_exporter: Arc::clone(&repo) as _,
|
||||
document_parser: Arc::clone(&repo) as _,
|
||||
review_logger: Arc::clone(&repo) as _,
|
||||
person_enrichment: None,
|
||||
#[cfg(feature = "federation")]
|
||||
ap_service: Arc::new(activitypub::NoopActivityPubService),
|
||||
},
|
||||
config: AppConfig {
|
||||
allow_registration: false,
|
||||
base_url: "http://localhost:3000".to_string(),
|
||||
rate_limit: 20,
|
||||
refresh_ttl_seconds: 2_592_000,
|
||||
wrapup: application::config::WrapUpConfig {
|
||||
font_path: None,
|
||||
logo_path: None,
|
||||
bg_dir: None,
|
||||
},
|
||||
},
|
||||
deps,
|
||||
services,
|
||||
config,
|
||||
instance,
|
||||
#[cfg(feature = "federation")]
|
||||
ap_document: Arc::new(domain::ports::noop::NoopApDocument),
|
||||
#[cfg(feature = "federation")]
|
||||
ap_blocklist: Arc::new(domain::ports::noop::NoopInstanceBlocklist),
|
||||
},
|
||||
rss_renderer: Arc::new(Panic),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppState {
|
||||
build_test_state_from(test_repositories(), auth_service)
|
||||
}
|
||||
|
||||
/// Reusable default test `AppState`: `Panic` stubs everywhere, real prebuilt `deps`.
|
||||
/// Callers that need a specific stub swapped in should use one of the
|
||||
/// `test_app_state_with_*` constructors below, which inject BEFORE `build_deps`
|
||||
/// runs — mutating the returned `AppState` afterwards would only reach `repos`,
|
||||
/// not the prebuilt `deps`.
|
||||
pub(crate) fn test_app_state() -> crate::state::AppState {
|
||||
make_test_state(Arc::new(Panic))
|
||||
}
|
||||
|
||||
/// Test state with `person_query` overridden BEFORE `build_deps` runs, so the
|
||||
/// stub is visible from both `app_ctx.repos.person_query` and every prebuilt
|
||||
/// deps struct that carries a `person_query` (e.g. `deps.person.get_person`).
|
||||
pub(crate) fn test_app_state_with_person_query(
|
||||
person_query: Arc<dyn PersonQuery>,
|
||||
) -> crate::state::AppState {
|
||||
let mut repos = test_repositories();
|
||||
repos.person_query = person_query;
|
||||
build_test_state_from(repos, Arc::new(Panic))
|
||||
}
|
||||
|
||||
/// Test state with `search_port` overridden BEFORE `build_deps` runs, so the
|
||||
/// stub is visible from both `app_ctx.repos.search_port` and any prebuilt deps
|
||||
/// struct that carries a `search_port`.
|
||||
pub(crate) fn test_app_state_with_search_port(
|
||||
search_port: Arc<dyn SearchPort>,
|
||||
) -> crate::state::AppState {
|
||||
let mut repos = test_repositories();
|
||||
repos.search_port = search_port;
|
||||
build_test_state_from(repos, Arc::new(Panic))
|
||||
}
|
||||
|
||||
/// Test state with `follow_graph` overridden BEFORE `build_deps` runs, so the
|
||||
/// stub is visible from both `app_ctx.repos.follow_graph` and the prebuilt
|
||||
/// `deps.social.query` that the social handlers actually read from.
|
||||
pub(crate) fn test_app_state_with_follow_graph(
|
||||
follow_graph: Arc<dyn FollowGraphQuery>,
|
||||
auth_service: Arc<dyn AuthService>,
|
||||
) -> crate::state::AppState {
|
||||
let mut repos = test_repositories();
|
||||
repos.follow_graph = follow_graph;
|
||||
build_test_state_from(repos, auth_service)
|
||||
}
|
||||
|
||||
/// Test state with `user` overridden BEFORE `build_deps` runs, so the stub is
|
||||
/// visible from both `app_ctx.repos.user` and `deps.users.authorize_admin.user`
|
||||
/// — the field the `AdminApiUser`/`AdminUser` extractors now read through.
|
||||
pub(crate) fn test_app_state_with_user(
|
||||
user: Arc<dyn UserRepository>,
|
||||
auth_service: Arc<dyn AuthService>,
|
||||
) -> crate::state::AppState {
|
||||
let mut repos = test_repositories();
|
||||
repos.user = user;
|
||||
build_test_state_from(repos, auth_service)
|
||||
}
|
||||
|
||||
/// A `UserRepository` whose `find_by_id` always fails, for exercising the
|
||||
/// repository-error rejection path of the admin extractors. Every other
|
||||
/// method panics — the admin extractors only ever call `find_by_id`.
|
||||
pub(crate) struct ErroringUserRepo;
|
||||
#[async_trait::async_trait]
|
||||
impl UserRepository for ErroringUserRepo {
|
||||
async fn find_by_email(
|
||||
&self,
|
||||
_: &domain::value_objects::Email,
|
||||
) -> Result<Option<domain::models::User>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn save(&self, _: &domain::models::User) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn find_by_id(&self, _: &UserId) -> Result<Option<domain::models::User>, DomainError> {
|
||||
Err(DomainError::InfrastructureError("db down".into()))
|
||||
}
|
||||
async fn find_by_username(
|
||||
&self,
|
||||
_: &domain::value_objects::Username,
|
||||
) -> Result<Option<domain::models::User>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn update_profile(
|
||||
&self,
|
||||
_: &UserId,
|
||||
_: &domain::models::UserProfile,
|
||||
) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a bare `User` with the given role, for the admin/non-admin extractor
|
||||
/// tests — only `id()` and `role()` are read by `authorize_admin::execute`.
|
||||
fn make_user_with_role(id: UserId, role: domain::models::UserRole) -> domain::models::User {
|
||||
domain::models::User::from_persistence(
|
||||
id,
|
||||
domain::value_objects::Email::new("extractor-test@example.com".into()).unwrap(),
|
||||
domain::value_objects::Username::new("extractor_test_user".into()).unwrap(),
|
||||
domain::value_objects::PasswordHash::new("hashed".into()).unwrap(),
|
||||
role,
|
||||
domain::models::UserProfile {
|
||||
display_name: None,
|
||||
bio: None,
|
||||
avatar_path: None,
|
||||
banner_path: None,
|
||||
also_known_as: None,
|
||||
profile_fields: vec![],
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Auth stub that accepts any bearer token and resolves it to a fixed user id,
|
||||
/// for tests that need to authenticate as a specific user rather than merely
|
||||
/// exercise the unauthenticated-request path.
|
||||
pub(crate) struct AcceptingAuth(pub UserId);
|
||||
#[async_trait::async_trait]
|
||||
impl AuthService for AcceptingAuth {
|
||||
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Routers ---
|
||||
|
||||
async fn protected_handler(user: AuthenticatedUser) -> String {
|
||||
@@ -843,6 +1003,12 @@ async fn optional_cookie_handler(user: OptionalCookieUser) -> String {
|
||||
async fn required_cookie_handler(user: RequiredCookieUser) -> String {
|
||||
user.0.value().to_string()
|
||||
}
|
||||
async fn admin_api_handler(user: AdminApiUser) -> String {
|
||||
user.0.value().to_string()
|
||||
}
|
||||
async fn admin_cookie_handler(user: AdminUser) -> String {
|
||||
user.0.value().to_string()
|
||||
}
|
||||
|
||||
fn router_protected(state: crate::state::AppState) -> Router {
|
||||
Router::new()
|
||||
@@ -854,6 +1020,16 @@ fn router_optional(state: crate::state::AppState) -> Router {
|
||||
.route("/optional", get(optional_cookie_handler))
|
||||
.with_state(state)
|
||||
}
|
||||
fn router_admin_api(state: crate::state::AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/admin-api", get(admin_api_handler))
|
||||
.with_state(state)
|
||||
}
|
||||
fn router_admin_cookie(state: crate::state::AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/admin-cookie", get(admin_cookie_handler))
|
||||
.with_state(state)
|
||||
}
|
||||
fn router_required(state: crate::state::AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/required", get(required_cookie_handler))
|
||||
@@ -864,7 +1040,7 @@ fn router_required(state: crate::state::AppState) -> Router {
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_auth_header_returns_401() {
|
||||
let app = router_protected(make_test_state(Arc::new(Panic)));
|
||||
let app = router_protected(test_app_state());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
@@ -879,7 +1055,7 @@ async fn missing_auth_header_returns_401() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn optional_cookie_user_returns_none_without_cookie() {
|
||||
let app = router_optional(make_test_state(Arc::new(Panic)));
|
||||
let app = router_optional(test_app_state());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
@@ -918,7 +1094,7 @@ async fn optional_cookie_user_returns_none_with_invalid_token() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn required_cookie_user_redirects_without_cookie() {
|
||||
let app = router_required(make_test_state(Arc::new(Panic)));
|
||||
let app = router_required(test_app_state());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
@@ -948,3 +1124,197 @@ async fn required_cookie_user_redirects_with_invalid_token() {
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(resp.headers().get("location").unwrap(), "/login");
|
||||
}
|
||||
|
||||
// --- AdminApiUser / AdminUser: per-path rejection pinning ---
|
||||
//
|
||||
// These pin the exact rejection for every path `authorize_admin::execute` can
|
||||
// produce, now that both extractors go through it instead of calling
|
||||
// `repos.user.find_by_id` directly. Each test name states the path; the
|
||||
// asserted status is the byte-identical-to-before-this-task value recorded in
|
||||
// the task-4 report's before/after table.
|
||||
|
||||
fn admin_id() -> UserId {
|
||||
UserId::generate()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_api_user_allows_an_admin_user() {
|
||||
let id = admin_id();
|
||||
let user_repo = InMemoryUserRepository::new();
|
||||
user_repo.store.lock().unwrap().insert(
|
||||
id.value(),
|
||||
make_user_with_role(id.clone(), domain::models::UserRole::Admin),
|
||||
);
|
||||
let app = router_admin_api(test_app_state_with_user(
|
||||
user_repo as _,
|
||||
Arc::new(AcceptingAuth(id)),
|
||||
));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/admin-api")
|
||||
.header("authorization", "Bearer anything")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_api_user_rejects_a_non_admin_user() {
|
||||
let id = admin_id();
|
||||
let user_repo = InMemoryUserRepository::new();
|
||||
user_repo.store.lock().unwrap().insert(
|
||||
id.value(),
|
||||
make_user_with_role(id.clone(), domain::models::UserRole::Standard),
|
||||
);
|
||||
let app = router_admin_api(test_app_state_with_user(
|
||||
user_repo as _,
|
||||
Arc::new(AcceptingAuth(id)),
|
||||
));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/admin-api")
|
||||
.header("authorization", "Bearer anything")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_api_user_rejects_an_unknown_user() {
|
||||
let id = admin_id();
|
||||
let user_repo = InMemoryUserRepository::new(); // empty — id is not in the store
|
||||
let app = router_admin_api(test_app_state_with_user(
|
||||
user_repo as _,
|
||||
Arc::new(AcceptingAuth(id)),
|
||||
));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/admin-api")
|
||||
.header("authorization", "Bearer anything")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_api_user_surfaces_a_repository_error_as_500() {
|
||||
let id = admin_id();
|
||||
let app = router_admin_api(test_app_state_with_user(
|
||||
Arc::new(ErroringUserRepo),
|
||||
Arc::new(AcceptingAuth(id)),
|
||||
));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/admin-api")
|
||||
.header("authorization", "Bearer anything")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_user_allows_an_admin_user() {
|
||||
let id = admin_id();
|
||||
let user_repo = InMemoryUserRepository::new();
|
||||
user_repo.store.lock().unwrap().insert(
|
||||
id.value(),
|
||||
make_user_with_role(id.clone(), domain::models::UserRole::Admin),
|
||||
);
|
||||
let app = router_admin_cookie(test_app_state_with_user(
|
||||
user_repo as _,
|
||||
Arc::new(AcceptingAuth(id)),
|
||||
));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/admin-cookie")
|
||||
.header("cookie", "token=anything")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_user_rejects_a_non_admin_user() {
|
||||
let id = admin_id();
|
||||
let user_repo = InMemoryUserRepository::new();
|
||||
user_repo.store.lock().unwrap().insert(
|
||||
id.value(),
|
||||
make_user_with_role(id.clone(), domain::models::UserRole::Standard),
|
||||
);
|
||||
let app = router_admin_cookie(test_app_state_with_user(
|
||||
user_repo as _,
|
||||
Arc::new(AcceptingAuth(id)),
|
||||
));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/admin-cookie")
|
||||
.header("cookie", "token=anything")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_user_rejects_an_unknown_user() {
|
||||
let id = admin_id();
|
||||
let user_repo = InMemoryUserRepository::new(); // empty — id is not in the store
|
||||
let app = router_admin_cookie(test_app_state_with_user(
|
||||
user_repo as _,
|
||||
Arc::new(AcceptingAuth(id)),
|
||||
));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/admin-cookie")
|
||||
.header("cookie", "token=anything")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_user_surfaces_a_repository_error_as_500() {
|
||||
let id = admin_id();
|
||||
let app = router_admin_cookie(test_app_state_with_user(
|
||||
Arc::new(ErroringUserRepo),
|
||||
Arc::new(AcceptingAuth(id)),
|
||||
));
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/admin-cookie")
|
||||
.header("cookie", "token=anything")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
mod api_handlers;
|
||||
mod context;
|
||||
|
||||
@@ -1,610 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use application::config::AppConfig;
|
||||
use async_trait::async_trait;
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{
|
||||
EntityType, ExternalPersonId, GeneratedToken, IndexableDocument, MetadataSearchCriteria,
|
||||
Movie, Person, PersonCredits, PersonEnrichmentData, PersonId, SearchQuery, SearchResults,
|
||||
User,
|
||||
},
|
||||
ports::{
|
||||
AuthService, EventPublisher, MetadataClient, ObjectStorage, PasswordHasher, PersonCommand,
|
||||
PersonQuery, PosterFetcherClient, SearchCommand, SearchPort, UserRepository,
|
||||
},
|
||||
value_objects::{Email, ExternalMetadataId, PasswordHash, PosterUrl, UserId},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use presentation::context::{AppContext, Repositories, Services};
|
||||
use presentation::{routes, state::AppState};
|
||||
use rss::RssAdapter;
|
||||
use sqlite::{
|
||||
SqliteDiaryRepository, SqliteMovieRepository, SqliteReviewRepository, SqliteStatsRepository,
|
||||
migrate as sqlite_migrate,
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
use tower::ServiceExt;
|
||||
|
||||
struct NoopEventPublisher;
|
||||
#[async_trait]
|
||||
impl EventPublisher for NoopEventPublisher {
|
||||
async fn publish(&self, _: &DomainEvent) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicReviewLogger;
|
||||
#[async_trait]
|
||||
impl application::ports::ReviewLogger for PanicReviewLogger {
|
||||
async fn log_review(
|
||||
&self,
|
||||
_: application::diary::commands::LogReviewCommand,
|
||||
) -> Result<(), DomainError> {
|
||||
panic!("review_logger not wired in tests")
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicMeta;
|
||||
#[async_trait]
|
||||
impl MetadataClient for PanicMeta {
|
||||
async fn fetch_movie_metadata(&self, _: &MetadataSearchCriteria) -> Result<Movie, DomainError> {
|
||||
panic!("metadata not wired in tests")
|
||||
}
|
||||
async fn get_poster_url(
|
||||
&self,
|
||||
_: &ExternalMetadataId,
|
||||
) -> Result<Option<PosterUrl>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicFetcher;
|
||||
#[async_trait]
|
||||
impl PosterFetcherClient for PanicFetcher {
|
||||
async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result<Vec<u8>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicObjectStorage;
|
||||
#[async_trait]
|
||||
impl ObjectStorage for PanicObjectStorage {
|
||||
async fn store(&self, _: &str, _: &[u8]) -> Result<String, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get(&self, _: &str) -> Result<Vec<u8>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_stream(
|
||||
&self,
|
||||
_: &str,
|
||||
) -> Result<futures::stream::BoxStream<'static, Result<bytes::Bytes, DomainError>>, DomainError>
|
||||
{
|
||||
panic!()
|
||||
}
|
||||
async fn delete(&self, _: &str) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicHasher;
|
||||
#[async_trait]
|
||||
impl PasswordHasher for PanicHasher {
|
||||
async fn hash(&self, _: &str) -> Result<PasswordHash, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn verify(&self, _: &str, _: &PasswordHash) -> Result<bool, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicAuth;
|
||||
#[async_trait]
|
||||
impl AuthService for PanicAuth {
|
||||
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
struct NobodyUserRepo;
|
||||
#[async_trait]
|
||||
impl UserRepository for NobodyUserRepo {
|
||||
async fn find_by_email(&self, _: &Email) -> Result<Option<User>, DomainError> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn find_by_username(
|
||||
&self,
|
||||
_: &domain::value_objects::Username,
|
||||
) -> Result<Option<User>, DomainError> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn save(&self, _: &User) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn find_by_id(&self, _: &UserId) -> Result<Option<User>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn update_profile(
|
||||
&self,
|
||||
_: &UserId,
|
||||
_: &domain::models::UserProfile,
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicProfileFields;
|
||||
#[async_trait]
|
||||
impl domain::ports::UserProfileFieldsRepository for PanicProfileFields {
|
||||
async fn get_fields(
|
||||
&self,
|
||||
_: &UserId,
|
||||
) -> Result<Vec<domain::models::ProfileField>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn set_fields(
|
||||
&self,
|
||||
_: &UserId,
|
||||
_: Vec<domain::models::ProfileField>,
|
||||
) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicExporter;
|
||||
impl domain::ports::DiaryExporter for PanicExporter {
|
||||
fn stream_entries(
|
||||
&self,
|
||||
_stream: futures::stream::BoxStream<
|
||||
'static,
|
||||
Result<domain::models::DiaryEntry, DomainError>,
|
||||
>,
|
||||
_format: domain::models::ExportFormat,
|
||||
) -> futures::stream::BoxStream<'static, Result<bytes::Bytes, DomainError>> {
|
||||
panic!("PanicExporter::stream_entries")
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicImportSession;
|
||||
#[async_trait]
|
||||
impl domain::ports::ImportSessionRepository for PanicImportSession {
|
||||
async fn create(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
_: &domain::value_objects::ImportSessionId,
|
||||
_: &UserId,
|
||||
) -> Result<Option<domain::models::ImportSession>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn update(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn delete(&self, _: &domain::value_objects::ImportSessionId) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn delete_expired_for_user(&self, _: &UserId) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicDocumentParser;
|
||||
impl domain::ports::DocumentParser for PanicDocumentParser {
|
||||
fn parse(
|
||||
&self,
|
||||
_: &[u8],
|
||||
_: domain::models::FileFormat,
|
||||
) -> Result<domain::models::ParsedFile, domain::models::ImportError> {
|
||||
panic!("DocumentParser not wired in tests")
|
||||
}
|
||||
fn apply_mapping(
|
||||
&self,
|
||||
_: &domain::models::ParsedFile,
|
||||
_: &[domain::models::FieldMapping],
|
||||
) -> Vec<domain::models::AnnotatedRow> {
|
||||
panic!("DocumentParser not wired in tests")
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicImportProfile;
|
||||
|
||||
struct PanicMovieProfile;
|
||||
#[async_trait]
|
||||
impl domain::ports::MovieProfileRepository for PanicMovieProfile {
|
||||
async fn upsert(&self, _: &domain::models::MovieProfile) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_by_movie_id(
|
||||
&self,
|
||||
_: &domain::value_objects::MovieId,
|
||||
) -> Result<Option<domain::models::MovieProfile>, DomainError> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn list_stale(
|
||||
&self,
|
||||
) -> Result<Vec<(domain::value_objects::MovieId, String)>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl domain::ports::ImportProfileRepository for PanicImportProfile {
|
||||
async fn save(&self, _: &domain::models::ImportProfile) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn list_for_user(
|
||||
&self,
|
||||
_: &UserId,
|
||||
) -> Result<Vec<domain::models::ImportProfile>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
_: &domain::value_objects::ImportProfileId,
|
||||
_: &UserId,
|
||||
) -> Result<Option<domain::models::ImportProfile>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn delete(&self, _: &domain::value_objects::ImportProfileId) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicWatchlist;
|
||||
#[async_trait]
|
||||
impl domain::ports::WatchlistRepository for PanicWatchlist {
|
||||
async fn add(&self, _: &domain::models::WatchlistEntry) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn remove(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
_: &domain::value_objects::MovieId,
|
||||
) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn remove_if_present(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
_: &domain::value_objects::MovieId,
|
||||
) -> Result<bool, DomainError> {
|
||||
Ok(false)
|
||||
}
|
||||
async fn get_for_user(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
_: &domain::models::collections::PageParams,
|
||||
) -> Result<
|
||||
domain::models::collections::Paginated<domain::models::WatchlistWithMovie>,
|
||||
DomainError,
|
||||
> {
|
||||
panic!()
|
||||
}
|
||||
async fn contains(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
_: &domain::value_objects::MovieId,
|
||||
) -> Result<bool, DomainError> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicPersonCommand;
|
||||
#[async_trait]
|
||||
impl PersonCommand for PanicPersonCommand {
|
||||
async fn upsert_batch(&self, _: &[Person]) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn backfill_from_credits_batch(
|
||||
&self,
|
||||
_batch_size: u32,
|
||||
) -> Result<(u64, bool), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn update_enrichment(
|
||||
&self,
|
||||
_: &PersonId,
|
||||
_: &PersonEnrichmentData,
|
||||
) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicPersonQuery;
|
||||
#[async_trait]
|
||||
impl PersonQuery for PanicPersonQuery {
|
||||
async fn get_by_id(&self, _: &PersonId) -> Result<Option<Person>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_by_external_id(
|
||||
&self,
|
||||
_: &ExternalPersonId,
|
||||
) -> Result<Option<Person>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_credits(&self, _: &PersonId) -> Result<PersonCredits, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn list_orphaned_persons(&self) -> Result<Vec<PersonId>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn list_page(
|
||||
&self,
|
||||
_limit: u32,
|
||||
_offset: u32,
|
||||
) -> Result<Vec<domain::models::Person>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicSearchPort;
|
||||
#[async_trait]
|
||||
impl SearchPort for PanicSearchPort {
|
||||
async fn search(&self, _: &SearchQuery) -> Result<SearchResults, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicSearchCommand;
|
||||
#[async_trait]
|
||||
impl SearchCommand for PanicSearchCommand {
|
||||
async fn index(&self, _: IndexableDocument) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn remove(&self, _: EntityType, _: &str) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
struct PanicRemoteWatchlist;
|
||||
#[cfg(feature = "federation")]
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::RemoteWatchlistRepository for PanicRemoteWatchlist {
|
||||
async fn save(&self, _: domain::models::RemoteWatchlistEntry) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn get_by_actor_url(
|
||||
&self,
|
||||
_: &str,
|
||||
) -> Result<Vec<domain::models::RemoteWatchlistEntry>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn get_by_derived_uuid(
|
||||
&self,
|
||||
_: uuid::Uuid,
|
||||
) -> Result<Vec<domain::models::RemoteWatchlistEntry>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
async fn test_app() -> Router {
|
||||
let pool = SqlitePool::connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("in-memory SQLite failed");
|
||||
sqlite_migrate(&pool).await.expect("migration failed");
|
||||
|
||||
let state = AppState {
|
||||
app_ctx: AppContext {
|
||||
repos: Repositories {
|
||||
movie_command: Arc::new(SqliteMovieRepository::new(pool.clone())) as _,
|
||||
movie_query: Arc::new(SqliteMovieRepository::new(pool.clone())) as _,
|
||||
review: Arc::new(SqliteReviewRepository::new(pool.clone())) as _,
|
||||
diary: Arc::new(SqliteDiaryRepository::new(pool.clone())) as _,
|
||||
stats: Arc::new(SqliteStatsRepository::new(pool.clone())) as _,
|
||||
user: Arc::new(NobodyUserRepo),
|
||||
import_session: Arc::new(PanicImportSession),
|
||||
import_profile: Arc::new(PanicImportProfile),
|
||||
movie_profile: Arc::new(PanicMovieProfile),
|
||||
watchlist: Arc::new(PanicWatchlist),
|
||||
watch_event_command: Arc::new(domain::testing::PanicWatchEventCommand),
|
||||
watch_event_query: Arc::new(domain::testing::PanicWatchEventQuery),
|
||||
webhook_token: Arc::new(domain::testing::PanicWebhookTokenRepository),
|
||||
profile_fields: Arc::new(PanicProfileFields),
|
||||
person_command: Arc::new(PanicPersonCommand),
|
||||
person_query: Arc::new(PanicPersonQuery),
|
||||
search_port: Arc::new(PanicSearchPort),
|
||||
search_command: Arc::new(PanicSearchCommand),
|
||||
remote_watchlist: Arc::new(PanicRemoteWatchlist),
|
||||
social_command: Arc::new(domain::ports::noop::NoopSocialCommand),
|
||||
social_query_unified: Arc::new(domain::ports::noop::NoopSocialQuery),
|
||||
federation_admin: Arc::new(domain::ports::noop::NoopFederationAdminQuery) as _,
|
||||
wrapup_stats: Arc::new(domain::testing::PanicWrapUpStatsQuery) as _,
|
||||
wrapup_repo: Arc::new(domain::testing::PanicWrapUpRepository) as _,
|
||||
goal_command: Arc::new(domain::testing::NoopGoalCommand),
|
||||
goal_query: Arc::new(domain::testing::NoopGoalQuery),
|
||||
user_settings: Arc::new(domain::testing::NoopUserSettingsRepository),
|
||||
remote_goal: Arc::new(domain::testing::NoopRemoteGoalRepository),
|
||||
refresh_session: Arc::new(domain::testing::PanicRefreshSessionRepository),
|
||||
federated_profile: None,
|
||||
},
|
||||
services: Services {
|
||||
auth: Arc::new(PanicAuth),
|
||||
password_hasher: Arc::new(PanicHasher),
|
||||
metadata: Arc::new(PanicMeta),
|
||||
poster_fetcher: Arc::new(PanicFetcher),
|
||||
object_storage: Arc::new(PanicObjectStorage),
|
||||
event_publisher: Arc::new(NoopEventPublisher),
|
||||
diary_exporter: Arc::new(PanicExporter),
|
||||
document_parser: Arc::new(PanicDocumentParser),
|
||||
review_logger: Arc::new(PanicReviewLogger),
|
||||
person_enrichment: None,
|
||||
#[cfg(feature = "federation")]
|
||||
ap_service: Arc::new(activitypub::NoopActivityPubService),
|
||||
},
|
||||
config: AppConfig {
|
||||
allow_registration: false,
|
||||
base_url: "http://localhost:3000".to_string(),
|
||||
rate_limit: 20,
|
||||
refresh_ttl_seconds: 2_592_000,
|
||||
wrapup: application::config::WrapUpConfig {
|
||||
font_path: None,
|
||||
logo_path: None,
|
||||
bg_dir: None,
|
||||
},
|
||||
},
|
||||
},
|
||||
rss_renderer: Arc::new(RssAdapter::new("http://localhost:3000".into())),
|
||||
};
|
||||
|
||||
routes::build_router(state, axum::Router::new())
|
||||
}
|
||||
|
||||
/// Inject a fake peer IP so the GovernorLayer can extract ConnectInfo.
|
||||
fn with_ip(req: Request<Body>) -> Request<Body> {
|
||||
let addr: std::net::SocketAddr = "127.0.0.1:12345".parse().unwrap();
|
||||
let mut req = req;
|
||||
req.extensions_mut()
|
||||
.insert(axum::extract::ConnectInfo::<std::net::SocketAddr>(addr));
|
||||
req
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_api_diary_returns_empty_list() {
|
||||
let app = test_app().await;
|
||||
let response = app
|
||||
.oneshot(with_ip(
|
||||
Request::builder()
|
||||
.uri("/api/v1/diary")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let bytes = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
|
||||
assert_eq!(json["total_count"], 0);
|
||||
assert_eq!(json["items"], serde_json::json!([]));
|
||||
assert_eq!(json["limit"], 5);
|
||||
assert_eq!(json["offset"], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_api_reviews_without_auth_returns_401() {
|
||||
let app = test_app().await;
|
||||
let response = app
|
||||
.oneshot(with_ip(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/reviews")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"rating":4,"watched_at":"2026-01-01T20:00:00","manual_title":"Dune","manual_release_year":2021}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_api_auth_login_unknown_user_returns_401() {
|
||||
let app = test_app().await;
|
||||
let response = app
|
||||
.oneshot(with_ip(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/auth/login")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"email":"a@b.com","password":"x"}"#))
|
||||
.unwrap(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_api_movie_detail_returns_404_for_unknown_id() {
|
||||
let app = test_app().await;
|
||||
let response = app
|
||||
.oneshot(with_ip(
|
||||
Request::builder()
|
||||
.uri("/api/v1/movies/00000000-0000-0000-0000-000000000000")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tags_moviesdiary_redirects_to_home() {
|
||||
let app = test_app().await;
|
||||
let response = app
|
||||
.oneshot(with_ip(
|
||||
Request::builder()
|
||||
.uri("/tags/moviesdiary")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
|
||||
assert_eq!(response.headers().get("location").unwrap(), "/");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tags_other_redirects_to_search() {
|
||||
let app = test_app().await;
|
||||
let response = app
|
||||
.oneshot(with_ip(
|
||||
Request::builder()
|
||||
.uri("/tags/batman")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
|
||||
assert_eq!(
|
||||
response.headers().get("location").unwrap(),
|
||||
"/?search=batman"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_movie_detail_html_returns_404_for_unknown_id() {
|
||||
let app = test_app().await;
|
||||
let response = app
|
||||
.oneshot(with_ip(
|
||||
Request::builder()
|
||||
.uri("/movies/00000000-0000-0000-0000-000000000000")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
Reference in New Issue
Block a user