fix(presentation): extract 12 handler violations to use cases

- TokenService port + JwtTokenService adapter; login/refresh return LoginResult
- delete get_token (dup of login); create_tokens helper removed
- type UpdateChannelRequest schedule_config/recycle_policy (no serde_json::Value)
- update_settings returns updated Vec; handler calls one use case
- config use case in application::config; handler maps DTO only
- SyncStatusEntry moved to api-types w/ From<LibrarySyncLogEntry>
- trigger_sync: Conflict error, drop sync_trigger.send from handler
- UpsertProviderCommand.config accepts Value; serialization in use case
- get_current_broadcast returns BroadcastWithChannel; one call
- get_stream_url resolves broadcast internally; handler single call
This commit is contained in:
2026-07-12 05:28:49 +02:00
parent 9b18d3ff6d
commit 33b440d297
44 changed files with 414 additions and 280 deletions

View File

@@ -3,7 +3,7 @@ use axum::extract::{Query, State};
use std::collections::HashMap;
use api_types::{ActivityEventResponse, ActivityLogParams, SettingsResponse};
use application::admin::{GetActivityLogQuery, GetSettingsQuery, UpdateSettingsCommand};
use application::admin::{GetActivityLogQuery, UpdateSettingsCommand};
use crate::errors::AppError;
use crate::extractors::AdminUser;
@@ -16,7 +16,7 @@ pub async fn get_settings(
AdminUser(_user): AdminUser,
) -> Result<Json<SettingsResponse>, AppError> {
let pairs =
application::admin::get_settings::execute(&state.admin_deps, GetSettingsQuery).await?;
application::admin::get_settings::execute(&state.admin_deps, application::admin::GetSettingsQuery).await?;
let settings: HashMap<String, String> = pairs.into_iter().collect();
Ok(Json(SettingsResponse { settings }))
}
@@ -30,10 +30,7 @@ pub async fn update_settings(
let cmd = UpdateSettingsCommand {
settings: settings_vec,
};
application::admin::update_settings::execute(&state.admin_deps, cmd).await?;
let pairs =
application::admin::get_settings::execute(&state.admin_deps, GetSettingsQuery).await?;
let pairs = application::admin::update_settings::execute(&state.admin_deps, cmd).await?;
let settings: HashMap<String, String> = pairs.into_iter().collect();
Ok(Json(SettingsResponse { settings }))
}

View File

@@ -3,14 +3,12 @@ use axum::extract::State;
use api_types::{LoginRequest, RefreshRequest, RegisterRequest, TokenResponse, UserResponse};
use application::auth::{LoginCommand, RegisterCommand};
use domain::DomainError;
use crate::errors::AppError;
use crate::extractors::CurrentUser;
use crate::state::AppState;
const TOKEN_TYPE_BEARER: &str = "Bearer";
const SECS_PER_HOUR: u64 = 3600;
pub async fn register(
State(state): State<AppState>,
@@ -31,14 +29,14 @@ pub async fn login(
let cmd = LoginCommand {
email: req.email,
password: req.password,
remember_me: req.remember_me,
};
let user = application::auth::login::execute(&state.auth_deps, cmd).await?;
let (access_token, refresh_token) = create_tokens(&user, &state, req.remember_me)?;
let result = application::auth::login::execute(&state.auth_deps, cmd).await?;
Ok(Json(TokenResponse {
access_token,
access_token: result.access_token,
token_type: TOKEN_TYPE_BEARER.to_string(),
expires_in: state.config.jwt_expiry_hours * SECS_PER_HOUR,
refresh_token,
expires_in: result.expires_in,
refresh_token: result.refresh_token,
}))
}
@@ -46,100 +44,21 @@ pub async fn logout() -> Result<Json<serde_json::Value>, AppError> {
Ok(Json(serde_json::json!({"message": "logged out"})))
}
pub async fn me(
CurrentUser(user): CurrentUser,
) -> Result<Json<UserResponse>, AppError> {
pub async fn me(CurrentUser(user): CurrentUser) -> Result<Json<UserResponse>, AppError> {
Ok(Json(UserResponse::from(user)))
}
#[cfg(feature = "auth-jwt")]
pub async fn get_token(
State(state): State<AppState>,
Json(req): Json<LoginRequest>,
) -> Result<Json<TokenResponse>, AppError> {
let cmd = LoginCommand {
email: req.email,
password: req.password,
};
let user = application::auth::login::execute(&state.auth_deps, cmd).await?;
let (access_token, refresh_token) = create_tokens(&user, &state, req.remember_me)?;
Ok(Json(TokenResponse {
access_token,
token_type: TOKEN_TYPE_BEARER.to_string(),
expires_in: state.config.jwt_expiry_hours * SECS_PER_HOUR,
refresh_token,
}))
}
#[cfg(feature = "auth-jwt")]
pub async fn refresh_token(
State(state): State<AppState>,
Json(req): Json<RefreshRequest>,
) -> Result<Json<TokenResponse>, AppError> {
let validator = state
.jwt_validator
.as_ref()
.ok_or_else(|| AppError(DomainError::InfrastructureError("JWT not configured".to_string())))?;
let claims = validator.validate_refresh_token(&req.refresh_token).map_err(|e| {
tracing::debug!("Refresh token validation failed: {:?}", e);
AppError(DomainError::Unauthenticated("Invalid refresh token".to_string()))
})?;
let user_id: uuid::Uuid = claims
.sub
.parse()
.map_err(|_| AppError(DomainError::Unauthenticated("Invalid user ID in token".to_string())))?;
let user = state
.auth_deps
.user_query
.find_by_id(domain::UserId::from(user_id))
.await
.map_err(|e| AppError(DomainError::InfrastructureError(format!("Failed to fetch user: {}", e))))?
.ok_or_else(|| AppError(DomainError::Unauthenticated("User not found".to_string())))?;
let (access_token, refresh_token) = create_tokens(&user, &state, true)?;
let result =
application::auth::refresh::execute(&state.auth_deps, req.refresh_token).await?;
Ok(Json(TokenResponse {
access_token,
access_token: result.access_token,
token_type: TOKEN_TYPE_BEARER.to_string(),
expires_in: state.config.jwt_expiry_hours * SECS_PER_HOUR,
refresh_token,
expires_in: result.expires_in,
refresh_token: result.refresh_token,
}))
}
fn create_tokens(
user: &domain::User,
state: &AppState,
remember_me: bool,
) -> Result<(String, Option<String>), AppError> {
#[cfg(feature = "auth-jwt")]
{
let validator = state
.jwt_validator
.as_ref()
.ok_or_else(|| AppError(DomainError::InfrastructureError("JWT not configured".to_string())))?;
let access = validator
.create_token(user)
.map_err(|e| AppError(DomainError::InfrastructureError(format!("Failed to create token: {}", e))))?;
let refresh = if remember_me {
Some(
validator
.create_refresh_token(user)
.map_err(|e| AppError(DomainError::InfrastructureError(format!("Failed to create refresh token: {}", e))))?,
)
} else {
None
};
Ok((access, refresh))
}
#[cfg(not(feature = "auth-jwt"))]
{
let _ = (user, state, remember_me);
Err(AppError(DomainError::InfrastructureError("JWT feature not enabled".to_string())))
}
}

View File

@@ -74,30 +74,14 @@ pub async fn update_channel(
Path(id): Path<uuid::Uuid>,
Json(req): Json<UpdateChannelRequest>,
) -> Result<Json<ChannelResponse>, AppError> {
let schedule_config = req
.schedule_config
.map(|v| {
serde_json::from_value(v)
.map_err(|e| AppError(DomainError::ValidationError(format!("Invalid schedule_config: {e}"))))
})
.transpose()?;
let recycle_policy = req
.recycle_policy
.map(|v| {
serde_json::from_value(v)
.map_err(|e| AppError(DomainError::ValidationError(format!("Invalid recycle_policy: {e}"))))
})
.transpose()?;
let cmd = UpdateChannelCommand {
channel_id: id.into(),
owner_id: user.id(),
name: req.name,
description: req.description.map(Some),
timezone: req.timezone,
schedule_config,
recycle_policy,
schedule_config: req.schedule_config.map(Into::into),
recycle_policy: req.recycle_policy,
auto_schedule: req.auto_schedule,
};
let channel = application::channels::update::execute(&state.channel_command_deps, cmd).await?;

View File

@@ -2,54 +2,28 @@ use axum::Json;
use axum::extract::State;
use api_types::{ConfigResponse, ProviderCapabilitiesResponse, ProviderInfo};
use application::config::GetConfigQuery;
use crate::errors::AppError;
use crate::state::AppState;
const FALLBACK_STREAMING_PROTOCOL: &str = "direct_file";
pub async fn get_config(
State(state): State<AppState>,
) -> Result<Json<ConfigResponse>, AppError> {
let registry = &state.provider_registry;
let provider_ids = registry.provider_ids();
let primary_id = registry.primary_id().to_string();
let providers: Vec<ProviderInfo> = provider_ids
.iter()
.filter_map(|id| {
registry.capabilities(id).map(|caps| ProviderInfo {
id: id.clone(),
capabilities: ProviderCapabilitiesResponse::from(caps),
})
let sys_config = application::config::get_config::execute(&state.config_deps, GetConfigQuery);
let providers: Vec<ProviderInfo> = sys_config
.providers
.into_iter()
.map(|p| ProviderInfo {
id: p.id,
capabilities: ProviderCapabilitiesResponse::from(p.capabilities),
})
.collect();
let primary_caps = registry
.capabilities(&primary_id)
.map(ProviderCapabilitiesResponse::from)
.unwrap_or(ProviderCapabilitiesResponse {
collections: false,
series: false,
genres: false,
tags: false,
decade: false,
search: false,
streaming_protocol: FALLBACK_STREAMING_PROTOCOL.to_string(),
rescan: false,
transcode: false,
});
let mut available_types = Vec::new();
#[cfg(feature = "jellyfin")]
available_types.push("jellyfin".to_string());
#[cfg(feature = "local-files")]
available_types.push("local_files".to_string());
let primary_caps = ProviderCapabilitiesResponse::from(sys_config.primary_capabilities);
Ok(Json(ConfigResponse {
allow_registration: state.config.allow_registration,
allow_registration: sys_config.allow_registration,
providers,
provider_capabilities: primary_caps,
available_provider_types: available_types,
available_provider_types: sys_config.available_provider_types,
}))
}

View File

@@ -1,10 +1,9 @@
use axum::Json;
use axum::extract::{Path, Query, State};
use serde::Serialize;
use api_types::{
CollectionResponse, GenresParams, LibraryItemResponse, LibrarySearchParams, PaginatedResponse,
ProviderParam, SeasonResponse, SeasonsParams, ShowResponse, ShowsParams,
ProviderParam, SeasonResponse, SeasonsParams, ShowResponse, ShowsParams, SyncStatusEntry,
};
use application::library::{
GetItemQuery, GetSyncStatusQuery, ListCollectionsQuery, ListGenresQuery, ListSeasonsQuery,
@@ -116,16 +115,6 @@ pub async fn list_genres(
Ok(Json(genres))
}
#[derive(Debug, Serialize)]
pub(crate) struct SyncStatusEntry {
provider_id: String,
started_at: String,
finished_at: String,
items_found: u32,
status: String,
error_msg: String,
}
pub async fn sync_status(
State(state): State<AppState>,
CurrentUser(_user): CurrentUser,
@@ -133,18 +122,7 @@ pub async fn sync_status(
let entries =
application::library::get_sync_status::execute(&state.library_query_deps, GetSyncStatusQuery)
.await?;
let result: Vec<SyncStatusEntry> = entries
.into_iter()
.map(|e| SyncStatusEntry {
provider_id: e.provider_id().to_string(),
started_at: e.started_at().to_string(),
finished_at: e.finished_at().unwrap_or("").to_string(),
items_found: e.items_found(),
status: e.status().to_string(),
error_msg: e.error_msg().unwrap_or("").to_string(),
})
.collect();
Ok(Json(result))
Ok(Json(entries.into_iter().map(SyncStatusEntry::from).collect()))
}
pub async fn trigger_sync(
@@ -152,17 +130,6 @@ pub async fn trigger_sync(
AdminUser(_user): AdminUser,
) -> Result<axum::http::StatusCode, AppError> {
let cmd = TriggerSyncCommand { provider_id: None };
application::library::sync::execute(&state.library_command_deps, cmd)
.await
.map_err(|e| {
if e.to_string().contains("already running") {
AppError(DomainError::Conflict(e.to_string()))
} else {
AppError::from(e)
}
})?;
let _ = state.sync_trigger.send(());
application::library::sync::execute(&state.library_command_deps, cmd).await?;
Ok(axum::http::StatusCode::ACCEPTED)
}

View File

@@ -43,13 +43,10 @@ pub async fn upsert_provider(
Path(id): Path<String>,
Json(req): Json<ProviderConfigRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let config_json = serde_json::to_string(&req.config)
.map_err(|e| AppError(DomainError::ValidationError(format!("Invalid config JSON: {e}"))))?;
let cmd = UpsertProviderCommand {
id,
provider_type: req.provider_type,
config_json,
config: req.config,
enabled: req.enabled,
};
application::providers::upsert::execute(&state.provider_deps, cmd).await?;

View File

@@ -44,20 +44,15 @@ pub async fn get_current_broadcast(
let query = GetCurrentBroadcastQuery { channel_id: id };
match application::schedule::get_current_broadcast::execute(&state.schedule_deps, query).await?
{
Some(broadcast) => {
let channel_query = application::channels::GetChannelQuery { channel_id: id.into() };
let channel = application::channels::get::execute(&state.channel_query_deps, channel_query).await?;
let slot_response = match &channel {
Some(ch) => SlotResponse::with_block_access(broadcast.slot().clone(), ch),
None => SlotResponse::from(broadcast.slot().clone()),
Some(result) => {
let slot_response = match &result.channel {
Some(ch) => SlotResponse::with_block_access(result.broadcast.slot().clone(), ch),
None => SlotResponse::from(result.broadcast.slot().clone()),
};
let block_access_mode = slot_response.block_access_mode.clone();
Ok(Json(CurrentBroadcastResponse {
slot: slot_response,
offset_secs: broadcast.offset_secs(),
offset_secs: result.broadcast.offset_secs(),
block_access_mode,
})
.into_response())
@@ -79,26 +74,13 @@ pub async fn get_stream(
State(state): State<AppState>,
Path(id): Path<uuid::Uuid>,
) -> Result<axum::response::Response, AppError> {
let broadcast_query = GetCurrentBroadcastQuery { channel_id: id };
let broadcast =
application::schedule::get_current_broadcast::execute(&state.schedule_deps, broadcast_query)
.await?;
match broadcast {
Some(b) => {
let stream_query = GetStreamUrlQuery {
channel_id: id,
item_id: b.slot().item().id().value().to_string(),
};
let url =
application::schedule::get_stream_url::execute(&state.schedule_deps, stream_query)
.await?;
Ok((
StatusCode::TEMPORARY_REDIRECT,
[("Location", url.as_str())],
)
.into_response())
}
let query = GetStreamUrlQuery { channel_id: id };
match application::schedule::get_stream_url::execute(&state.schedule_deps, query).await? {
Some(url) => Ok((
StatusCode::TEMPORARY_REDIRECT,
[("Location", url.as_str())],
)
.into_response()),
None => Ok(StatusCode::NO_CONTENT.into_response()),
}
}