refactor: http-axum split into api/html/federation modules, router.rs, templates per page group
This commit is contained in:
101
crates/adapters/http-axum/src/api/auth.rs
Normal file
101
crates/adapters/http-axum/src/api/auth.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use crate::{errors::ApiError, state::AppState};
|
||||
use api_types::{
|
||||
LoginRequest, LoginResponse, LogoutRequest, RefreshRequest, RefreshResponse, RegisterRequest,
|
||||
};
|
||||
use application::auth::{
|
||||
commands::RegisterCommand, login as login_uc, queries::LoginCommand, register as register_uc,
|
||||
};
|
||||
use axum::{Json, extract::State, http::StatusCode};
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/auth/login",
|
||||
request_body = LoginRequest,
|
||||
responses(
|
||||
(status = 200, body = LoginResponse),
|
||||
(status = 401, description = "Invalid credentials"),
|
||||
)
|
||||
)]
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<LoginResponse>, ApiError> {
|
||||
let result = login_uc::execute(
|
||||
&state.app_ctx.deps.auth.login,
|
||||
LoginCommand {
|
||||
email: req.email,
|
||||
password: req.password,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(LoginResponse {
|
||||
token: result.token,
|
||||
refresh_token: result.refresh_token,
|
||||
user_id: result.user_id,
|
||||
email: result.email,
|
||||
expires_at: result.expires_at.to_rfc3339(),
|
||||
role: result.role,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/auth/register",
|
||||
request_body = RegisterRequest,
|
||||
responses(
|
||||
(status = 201, description = "User registered"),
|
||||
(status = 400, description = "Invalid input"),
|
||||
)
|
||||
)]
|
||||
pub async fn register(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
register_uc::execute(
|
||||
&state.app_ctx.deps.auth.register,
|
||||
RegisterCommand {
|
||||
email: req.email,
|
||||
username: req.username,
|
||||
password: req.password,
|
||||
role: domain::models::UserRole::Standard,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/auth/refresh",
|
||||
request_body = RefreshRequest,
|
||||
responses(
|
||||
(status = 200, body = RefreshResponse),
|
||||
(status = 401, description = "Invalid or expired refresh token"),
|
||||
)
|
||||
)]
|
||||
pub async fn refresh(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<RefreshRequest>,
|
||||
) -> Result<Json<RefreshResponse>, ApiError> {
|
||||
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,
|
||||
expires_at: result.expires_at.to_rfc3339(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/auth/logout",
|
||||
request_body = LogoutRequest,
|
||||
responses(
|
||||
(status = 204, description = "Logged out"),
|
||||
)
|
||||
)]
|
||||
pub async fn api_logout(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<LogoutRequest>,
|
||||
) -> StatusCode {
|
||||
let _ = application::auth::logout::execute(&state.app_ctx.deps.auth.logout, &req.refresh_token)
|
||||
.await;
|
||||
StatusCode::NO_CONTENT
|
||||
}
|
||||
191
crates/adapters/http-axum/src/api/diary.rs
Normal file
191
crates/adapters/http-axum/src/api/diary.rs
Normal file
@@ -0,0 +1,191 @@
|
||||
use crate::support::build_export_response;
|
||||
use crate::{
|
||||
errors::ApiError,
|
||||
extractors::AuthenticatedUser,
|
||||
html::forms::{LogReviewData, to_diary_query},
|
||||
state::AppState,
|
||||
};
|
||||
use api_types::{
|
||||
ActivityFeedQueryParams, ActivityFeedResponse, DiaryQueryParams, DiaryResponse,
|
||||
EditReviewRequest, ExportQueryParams, LogReviewRequest,
|
||||
};
|
||||
use application::diary::{
|
||||
commands::{DeleteReviewCommand, EditReviewCommand},
|
||||
delete_review, edit_review, get_activity_feed as get_feed_uc, get_diary, log_review,
|
||||
queries::GetActivityFeedQuery,
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/diary",
|
||||
params(DiaryQueryParams),
|
||||
responses(
|
||||
(status = 200, body = DiaryResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
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.deps.diary.get_diary, to_diary_query(params)).await?;
|
||||
|
||||
Ok(Json(DiaryResponse {
|
||||
items: page
|
||||
.items
|
||||
.iter()
|
||||
.map(api_types::mappers::entry_to_dto)
|
||||
.collect(),
|
||||
total_count: page.total_count,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/reviews",
|
||||
request_body = LogReviewRequest,
|
||||
responses(
|
||||
(status = 201, description = "Review created"),
|
||||
(status = 400, description = "Invalid input"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn post_review(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(req): Json<LogReviewRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let data = LogReviewData::try_from(req).map_err(ApiError)?;
|
||||
log_review::execute(
|
||||
&state.app_ctx.services.review_logger,
|
||||
data.into_command(user.0.value()),
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/v1/reviews/{id}",
|
||||
params(("id" = Uuid, Path, description = "Review ID")),
|
||||
responses(
|
||||
(status = 204, description = "Review deleted"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden"),
|
||||
(status = 404, description = "Review not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_review(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(review_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let cmd = DeleteReviewCommand {
|
||||
review_id,
|
||||
requesting_user_id: user_id.value(),
|
||||
};
|
||||
delete_review::execute(&state.app_ctx.deps.diary.delete_review, cmd).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch, path = "/api/v1/reviews/{id}",
|
||||
request_body = EditReviewRequest,
|
||||
params(("id" = Uuid, Path, description = "Review ID")),
|
||||
responses(
|
||||
(status = 200, description = "Review updated"),
|
||||
(status = 400, description = "Invalid input"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden"),
|
||||
(status = 404, description = "Review not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn patch_review(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(review_id): Path<Uuid>,
|
||||
Json(req): Json<EditReviewRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let watched_at = req
|
||||
.watched_at
|
||||
.map(|s| domain::value_objects::parse_watched_at(&s).map_err(ApiError))
|
||||
.transpose()?;
|
||||
|
||||
let cmd = EditReviewCommand {
|
||||
review_id,
|
||||
requesting_user_id: user_id.value(),
|
||||
rating: req.rating,
|
||||
comment: req.comment,
|
||||
watched_at,
|
||||
watch_medium: req.watch_medium,
|
||||
spoilers: req.spoilers,
|
||||
};
|
||||
edit_review::execute(&state.app_ctx.deps.diary.edit_review, cmd).await?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/diary/export",
|
||||
params(ExportQueryParams),
|
||||
responses(
|
||||
(status = 200, description = "Diary file download", content_type = "text/csv"),
|
||||
(status = 400, description = "Invalid format parameter"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn export_diary(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Query(params): Query<ExportQueryParams>,
|
||||
) -> impl IntoResponse {
|
||||
build_export_response(¶ms.format, user.0.value(), &state)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/activity-feed",
|
||||
params(ActivityFeedQueryParams),
|
||||
responses((status = 200, body = ActivityFeedResponse)),
|
||||
)]
|
||||
pub async fn get_activity_feed(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ActivityFeedQueryParams>,
|
||||
) -> Result<Json<ActivityFeedResponse>, ApiError> {
|
||||
let page = get_feed_uc::execute(
|
||||
&state.app_ctx.deps.diary.get_activity_feed,
|
||||
GetActivityFeedQuery {
|
||||
limit: params.limit.unwrap_or(20),
|
||||
offset: params.offset.unwrap_or(0),
|
||||
sort_by: params
|
||||
.sort_by
|
||||
.as_deref()
|
||||
.map(|s| s.parse().unwrap_or_default())
|
||||
.unwrap_or_default(),
|
||||
search: None,
|
||||
viewer_user_id: None,
|
||||
filter_following: false,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ActivityFeedResponse {
|
||||
items: page
|
||||
.items
|
||||
.iter()
|
||||
.map(api_types::mappers::feed_entry_to_dto)
|
||||
.collect(),
|
||||
total_count: page.total_count,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
}))
|
||||
}
|
||||
189
crates/adapters/http-axum/src/api/goals.rs
Normal file
189
crates/adapters/http-axum/src/api/goals.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
|
||||
use api_types::mappers::goal_with_progress_to_dto;
|
||||
use api_types::{
|
||||
CreateGoalRequest, GoalDto, GoalsResponse, UpdateGoalRequest, UpdateUserSettingsRequest,
|
||||
UserSettingsDto,
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/goals",
|
||||
responses(
|
||||
(status = 200, body = GoalsResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn list_goals(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<GoalsResponse>, ApiError> {
|
||||
let goals = application::goals::list::execute(
|
||||
&state.app_ctx.deps.goals.query,
|
||||
application::goals::queries::ListGoalsQuery {
|
||||
user_id: user.0.value(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(GoalsResponse {
|
||||
goals: goals.iter().map(goal_with_progress_to_dto).collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/goals",
|
||||
request_body = CreateGoalRequest,
|
||||
responses(
|
||||
(status = 200, body = GoalDto),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn create_goal(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(req): Json<CreateGoalRequest>,
|
||||
) -> Result<Json<GoalDto>, ApiError> {
|
||||
let g = application::goals::create::execute(
|
||||
&state.app_ctx.deps.goals.command,
|
||||
application::goals::commands::CreateGoalCommand {
|
||||
user_id: user.0.value(),
|
||||
year: req.year,
|
||||
target_count: req.target_count,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(goal_with_progress_to_dto(&g)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put, path = "/api/v1/goals/{year}",
|
||||
request_body = UpdateGoalRequest,
|
||||
responses(
|
||||
(status = 200, body = GoalDto),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Goal not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn update_goal(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Path(year): Path<u16>,
|
||||
Json(req): Json<UpdateGoalRequest>,
|
||||
) -> Result<Json<GoalDto>, ApiError> {
|
||||
let g = application::goals::update::execute(
|
||||
&state.app_ctx.deps.goals.command,
|
||||
application::goals::commands::UpdateGoalCommand {
|
||||
user_id: user.0.value(),
|
||||
year,
|
||||
target_count: req.target_count,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(goal_with_progress_to_dto(&g)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/v1/goals/{year}",
|
||||
responses(
|
||||
(status = 204, description = "Goal deleted"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Goal not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_goal(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Path(year): Path<u16>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
application::goals::delete::execute(
|
||||
&state.app_ctx.deps.goals.command,
|
||||
application::goals::commands::DeleteGoalCommand {
|
||||
user_id: user.0.value(),
|
||||
year,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/users/{id}/goals",
|
||||
responses(
|
||||
(status = 200, body = GoalsResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_user_goals(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(_viewer): AuthenticatedUser,
|
||||
Path(user_id): Path<Uuid>,
|
||||
) -> Result<Json<GoalsResponse>, ApiError> {
|
||||
let goals = application::goals::list::execute(
|
||||
&state.app_ctx.deps.goals.query,
|
||||
application::goals::queries::ListGoalsQuery { user_id },
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(GoalsResponse {
|
||||
goals: goals.iter().map(goal_with_progress_to_dto).collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/settings",
|
||||
responses(
|
||||
(status = 200, body = UserSettingsDto),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_settings(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<UserSettingsDto>, ApiError> {
|
||||
let settings = application::users::get_settings::execute(
|
||||
&state.app_ctx.deps.users.get_settings,
|
||||
user.0.value(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(UserSettingsDto {
|
||||
federate_goals: settings.federate_goals(),
|
||||
federate_reviews: settings.federate_reviews(),
|
||||
federate_watchlist: settings.federate_watchlist(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put, path = "/api/v1/settings",
|
||||
request_body = UpdateUserSettingsRequest,
|
||||
responses(
|
||||
(status = 204, description = "Settings updated"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn update_settings(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(req): Json<UpdateUserSettingsRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
application::users::update_settings::execute(
|
||||
&state.app_ctx.deps.users.update_settings,
|
||||
application::users::update_settings::UpdateUserSettingsCommand {
|
||||
user_id: user.0.value(),
|
||||
federate_goals: req.federate_goals,
|
||||
federate_reviews: req.federate_reviews,
|
||||
federate_watchlist: req.federate_watchlist,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
5
crates/adapters/http-axum/src/api/import/mod.rs
Normal file
5
crates/adapters/http-axum/src/api/import/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod profiles;
|
||||
mod sessions;
|
||||
|
||||
pub use profiles::*;
|
||||
pub use sessions::*;
|
||||
105
crates/adapters/http-axum/src/api/import/profiles.rs
Normal file
105
crates/adapters/http-axum/src/api/import/profiles.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
|
||||
use api_types::SaveProfileRequest;
|
||||
use application::import::{
|
||||
commands::{DeleteImportProfileCommand, SaveImportProfileCommand},
|
||||
delete_profile as delete_import_profile, list_profiles as list_import_profiles,
|
||||
save_profile as save_import_profile,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use domain::errors::DomainError;
|
||||
use domain::value_objects::ImportSessionId;
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/import/profiles",
|
||||
responses(
|
||||
(status = 200, description = "List of saved import profiles", body = inline(Vec<serde_json::Value>)),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn api_get_profiles(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let profiles =
|
||||
list_import_profiles::execute(&state.app_ctx.deps.import.list_profiles, &user_id).await?;
|
||||
Ok(axum::Json(
|
||||
profiles
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
serde_json::json!({
|
||||
"id": p.id.value().to_string(),
|
||||
"name": p.name,
|
||||
"created_at": p.created_at.to_string(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/import/profiles",
|
||||
request_body = SaveProfileRequest,
|
||||
responses(
|
||||
(status = 200, description = "Profile saved", body = inline(serde_json::Value)),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 422, description = "Session has no mapping yet"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn api_post_profile(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
axum::Json(body): axum::Json<SaveProfileRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let session_id = body
|
||||
.session_id
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
|
||||
let id = save_import_profile::execute(
|
||||
&state.app_ctx.deps.import.save_profile,
|
||||
SaveImportProfileCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id: session_id.value(),
|
||||
name: body.name,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::Json(
|
||||
serde_json::json!({"id": id.value().to_string()}),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/v1/import/profiles/{id}",
|
||||
params(("id" = String, Path, description = "Import profile UUID")),
|
||||
responses(
|
||||
(status = 204, description = "Deleted"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Profile not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn api_delete_profile(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(profile_id_str): Path<String>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let profile_id = profile_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map_err(|_| DomainError::ValidationError("invalid profile id".into()))?;
|
||||
delete_import_profile::execute(
|
||||
&state.app_ctx.deps.import.delete_profile,
|
||||
DeleteImportProfileCommand {
|
||||
user_id: user_id.value(),
|
||||
profile_id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
309
crates/adapters/http-axum/src/api/import/sessions.rs
Normal file
309
crates/adapters/http-axum/src/api/import/sessions.rs
Normal file
@@ -0,0 +1,309 @@
|
||||
use crate::support::str_to_domain_field;
|
||||
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
|
||||
use api_types::{
|
||||
ApplyMappingRequest, ConfirmRequest, ImportPreviewResponse, PreviewRowDto,
|
||||
SessionCreatedResponse, SessionStateResponse,
|
||||
};
|
||||
use application::import::{
|
||||
apply_mapping as apply_import_mapping, apply_profile_and_map,
|
||||
commands::{
|
||||
ApplyImportMappingCommand, ApplyProfileAndMapCommand, CreateImportSessionCommand,
|
||||
ExecuteImportCommand,
|
||||
},
|
||||
create_session as create_import_session, execute as execute_import, get_preview_stage,
|
||||
get_session_state,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Multipart, Path, State},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use domain::errors::DomainError;
|
||||
use domain::models::{
|
||||
AnnotatedRow, FieldMapping, FileFormat,
|
||||
import::{DomainField, Transform},
|
||||
};
|
||||
use domain::value_objects::ImportSessionId;
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/import/sessions",
|
||||
request_body(content_type = "multipart/form-data", description = "file (binary) + format (csv|json|xlsx)"),
|
||||
responses(
|
||||
(status = 200, body = SessionCreatedResponse),
|
||||
(status = 400, description = "No file provided"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 422, description = "Parse error"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn api_post_session(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let mut file_bytes: Option<Vec<u8>> = None;
|
||||
let mut format_str = "csv".to_string();
|
||||
while let Ok(Some(field)) = multipart.next_field().await {
|
||||
match field.name() {
|
||||
Some("file") => {
|
||||
if let Ok(b) = field.bytes().await {
|
||||
file_bytes = Some(b.to_vec());
|
||||
}
|
||||
}
|
||||
Some("format") => {
|
||||
if let Ok(t) = field.text().await {
|
||||
format_str = t;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let bytes = match file_bytes {
|
||||
Some(b) if !b.is_empty() => b,
|
||||
_ => return Err(DomainError::ValidationError("no file".into()).into()),
|
||||
};
|
||||
let format = match format_str.as_str() {
|
||||
"json" => FileFormat::Json,
|
||||
"xlsx" => FileFormat::Xlsx,
|
||||
_ => FileFormat::Csv,
|
||||
};
|
||||
let r = create_import_session::execute(
|
||||
&state.app_ctx.deps.import.create_session,
|
||||
CreateImportSessionCommand {
|
||||
user_id: user_id.value(),
|
||||
bytes,
|
||||
format,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::Json(SessionCreatedResponse {
|
||||
session_id: r.session_id.value().to_string(),
|
||||
columns: r.columns,
|
||||
sample_rows: r.sample_rows,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/import/sessions/{id}",
|
||||
params(("id" = String, Path, description = "Import session UUID")),
|
||||
responses(
|
||||
(status = 200, body = SessionStateResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Session not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn api_get_session(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let session_id = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
|
||||
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: state_data.columns,
|
||||
has_mappings: state_data.has_mappings,
|
||||
row_count: state_data.row_count,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put, path = "/api/v1/import/sessions/{id}/mapping",
|
||||
params(("id" = String, Path, description = "Import session UUID")),
|
||||
request_body = ApplyMappingRequest,
|
||||
responses(
|
||||
(status = 200, description = "Mapping applied", body = inline(serde_json::Value)),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 422, description = "Mapping error"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn api_put_mapping(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
axum::Json(body): axum::Json<ApplyMappingRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let session_id = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
|
||||
let mappings: Vec<FieldMapping> = body
|
||||
.mappings
|
||||
.into_iter()
|
||||
.filter_map(|m| {
|
||||
let domain_field = str_to_domain_field(&m.domain_field)?;
|
||||
let transform = if domain_field == DomainField::Rating {
|
||||
Transform::RatingScale(m.rating_scale.unwrap_or(1.0))
|
||||
} else if domain_field == DomainField::WatchedAt {
|
||||
m.date_format
|
||||
.map(Transform::DateFormat)
|
||||
.unwrap_or(Transform::Identity)
|
||||
} else {
|
||||
Transform::Identity
|
||||
};
|
||||
Some(FieldMapping {
|
||||
source_column: m.source_column,
|
||||
domain_field,
|
||||
transform,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let rows = apply_import_mapping::execute(
|
||||
&state.app_ctx.deps.import.apply_mapping,
|
||||
ApplyImportMappingCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id: session_id.value(),
|
||||
mappings,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::Json(serde_json::json!({"row_count": rows.len()})))
|
||||
}
|
||||
|
||||
pub async fn api_get_preview(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
) -> Result<axum::Json<ImportPreviewResponse>, ApiError> {
|
||||
let session_id = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
|
||||
|
||||
let stage = get_preview_stage::execute(
|
||||
&state.app_ctx.deps.import.get_preview_stage,
|
||||
session_id,
|
||||
user_id.value(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 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()
|
||||
.map(|(i, a)| {
|
||||
use domain::models::import::RowResult;
|
||||
match &a.result {
|
||||
RowResult::Valid(row) if a.is_duplicate => {
|
||||
PreviewRowDto::Duplicate(api_types::PreviewRowData {
|
||||
index: i,
|
||||
title: row.title.clone(),
|
||||
release_year: row.release_year.clone(),
|
||||
director: row.director.clone(),
|
||||
rating: row.rating.clone(),
|
||||
watched_at: row.watched_at.clone(),
|
||||
comment: row.comment.clone(),
|
||||
})
|
||||
}
|
||||
RowResult::Valid(row) => PreviewRowDto::Valid(api_types::PreviewRowData {
|
||||
index: i,
|
||||
title: row.title.clone(),
|
||||
release_year: row.release_year.clone(),
|
||||
director: row.director.clone(),
|
||||
rating: row.rating.clone(),
|
||||
watched_at: row.watched_at.clone(),
|
||||
comment: row.comment.clone(),
|
||||
}),
|
||||
RowResult::Invalid { errors, .. } => PreviewRowDto::Invalid {
|
||||
index: i,
|
||||
errors: errors.clone(),
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(axum::Json(ImportPreviewResponse { rows }))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/import/sessions/{id}/confirm",
|
||||
params(("id" = String, Path, description = "Import session UUID")),
|
||||
request_body = ConfirmRequest,
|
||||
responses(
|
||||
(status = 200, description = "Import summary", body = inline(serde_json::Value)),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Session not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn api_post_confirm(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
axum::Json(body): axum::Json<ConfirmRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let session_id = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
|
||||
let s = execute_import::execute(
|
||||
&state.app_ctx.deps.import.execute_import,
|
||||
ExecuteImportCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id: session_id.value(),
|
||||
confirmed_indices: body.confirmed_indices,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(axum::Json(serde_json::json!({
|
||||
"imported": s.imported,
|
||||
"skipped_duplicates": s.skipped_duplicates,
|
||||
"failed": s.failed.iter().map(|(i, e)| serde_json::json!({"index": i, "error": e})).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put, path = "/api/v1/import/sessions/{id}/profile/{profile_id}",
|
||||
params(
|
||||
("id" = String, Path, description = "Import session UUID"),
|
||||
("profile_id" = String, Path, description = "Import profile UUID"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Profile applied and mapping regenerated", body = inline(serde_json::Value)),
|
||||
(status = 400, description = "Invalid ID"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Session or profile not found"),
|
||||
(status = 422, description = "Mapping error"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn api_apply_profile(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
Path((session_id_str, profile_id_str)): Path<(String, String)>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let session_id = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
|
||||
let profile_id = profile_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map_err(|_| DomainError::ValidationError("invalid profile id".into()))?;
|
||||
|
||||
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?;
|
||||
Ok(axum::Json(serde_json::json!({"row_count": rows.len()})))
|
||||
}
|
||||
93
crates/adapters/http-axum/src/api/insights.rs
Normal file
93
crates/adapters/http-axum/src/api/insights.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
|
||||
use api_types::insights::{InsightDto, InsightsQueryParams, InsightsResponse};
|
||||
use application::insights::get as get_insights_uc;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
};
|
||||
use domain::{errors::DomainError, models::Insight};
|
||||
|
||||
fn to_dto(insight: Insight) -> InsightDto {
|
||||
match insight {
|
||||
Insight::RatingLift {
|
||||
genre,
|
||||
genre_avg,
|
||||
user_avg,
|
||||
delta,
|
||||
sample_count,
|
||||
} => InsightDto::RatingLift {
|
||||
genre,
|
||||
genre_avg,
|
||||
user_avg,
|
||||
delta,
|
||||
sample_count,
|
||||
},
|
||||
Insight::Seasonality {
|
||||
genre,
|
||||
month,
|
||||
multiplier,
|
||||
sample_count,
|
||||
} => InsightDto::Seasonality {
|
||||
genre,
|
||||
month,
|
||||
multiplier,
|
||||
sample_count,
|
||||
},
|
||||
Insight::MediumTiming {
|
||||
medium,
|
||||
multiplier,
|
||||
weekend_share,
|
||||
baseline_share,
|
||||
sample_count,
|
||||
} => InsightDto::MediumTiming {
|
||||
medium,
|
||||
multiplier,
|
||||
weekend_share,
|
||||
baseline_share,
|
||||
sample_count,
|
||||
},
|
||||
Insight::Rhythm {
|
||||
peak_hour_start,
|
||||
peak_hour_end,
|
||||
top_days,
|
||||
sample_count,
|
||||
} => InsightDto::Rhythm {
|
||||
peak_hour_start,
|
||||
peak_hour_end,
|
||||
top_days,
|
||||
sample_count,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Live insights for the authenticated user's own diary. Private surface:
|
||||
/// habits (late-night watching, mediums) are more sensitive than the taste
|
||||
/// statistics already public on profiles (ADR-0010).
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/insights",
|
||||
params(InsightsQueryParams),
|
||||
responses(
|
||||
(status = 200, body = InsightsResponse),
|
||||
(status = 400, description = "Unknown time zone"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_insights(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Query(params): Query<InsightsQueryParams>,
|
||||
) -> Result<Json<InsightsResponse>, ApiError> {
|
||||
let tz: chrono_tz::Tz = match params.tz.as_deref() {
|
||||
None => chrono_tz::UTC,
|
||||
Some(name) => name.parse().map_err(|_| {
|
||||
ApiError(DomainError::ValidationError(format!(
|
||||
"unknown time zone: {name}"
|
||||
)))
|
||||
})?,
|
||||
};
|
||||
let insights = get_insights_uc::execute(&state.app_ctx.deps.insights.get, user.0, &tz).await?;
|
||||
Ok(Json(InsightsResponse {
|
||||
insights: insights.into_iter().map(to_dto).collect(),
|
||||
}))
|
||||
}
|
||||
201
crates/adapters/http-axum/src/api/mod.rs
Normal file
201
crates/adapters/http-axum/src/api/mod.rs
Normal file
@@ -0,0 +1,201 @@
|
||||
pub mod auth;
|
||||
pub mod diary;
|
||||
pub mod goals;
|
||||
pub mod import;
|
||||
pub mod insights;
|
||||
pub mod movies;
|
||||
pub mod search;
|
||||
pub mod social;
|
||||
pub mod user_profile;
|
||||
pub mod users;
|
||||
pub mod watchlist;
|
||||
pub mod webhook;
|
||||
pub mod wrapup;
|
||||
|
||||
use axum::{Router, routing};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// JSON API routes (mounted under `/api/v1`), minus the webhook ingestion
|
||||
/// endpoints ([`webhook_router`], separately rate limited) and the
|
||||
/// federation-gated admin endpoints (`federation::api_routes`).
|
||||
pub fn router() -> Router<AppState> {
|
||||
let base = Router::new()
|
||||
.route("/diary", routing::get(diary::get_diary))
|
||||
.route(
|
||||
"/movies/{id}/history",
|
||||
routing::get(movies::get_review_history),
|
||||
)
|
||||
.route("/movies", routing::get(movies::list_movies))
|
||||
.route("/movies/{id}", routing::get(movies::get_movie_detail))
|
||||
.route(
|
||||
"/movies/{id}/profile",
|
||||
routing::get(movies::get_movie_profile),
|
||||
)
|
||||
.route("/reviews", routing::post(diary::post_review))
|
||||
.route(
|
||||
"/reviews/{id}",
|
||||
routing::delete(diary::delete_review).patch(diary::patch_review),
|
||||
)
|
||||
.route(
|
||||
"/movies/{id}/sync-poster",
|
||||
routing::post(movies::sync_poster),
|
||||
)
|
||||
.route("/auth/login", routing::post(auth::login))
|
||||
.route("/auth/register", routing::post(auth::register))
|
||||
.route("/auth/refresh", routing::post(auth::refresh))
|
||||
.route("/auth/logout", routing::post(auth::api_logout))
|
||||
.route("/diary/export", routing::get(diary::export_diary))
|
||||
.route("/activity-feed", routing::get(diary::get_activity_feed))
|
||||
.route("/users", routing::get(users::list_users))
|
||||
.route("/users/{id}", routing::get(user_profile::get_user_profile))
|
||||
.route("/import/sessions", routing::post(import::api_post_session))
|
||||
.route(
|
||||
"/import/sessions/{id}",
|
||||
routing::get(import::api_get_session),
|
||||
)
|
||||
.route(
|
||||
"/import/sessions/{id}/mapping",
|
||||
routing::put(import::api_put_mapping),
|
||||
)
|
||||
.route(
|
||||
"/import/sessions/{id}/preview",
|
||||
routing::get(import::api_get_preview),
|
||||
)
|
||||
.route(
|
||||
"/import/sessions/{id}/confirm",
|
||||
routing::post(import::api_post_confirm),
|
||||
)
|
||||
.route(
|
||||
"/import/profiles",
|
||||
routing::get(import::api_get_profiles).post(import::api_post_profile),
|
||||
)
|
||||
.route(
|
||||
"/import/profiles/{id}",
|
||||
routing::delete(import::api_delete_profile),
|
||||
)
|
||||
.route(
|
||||
"/import/sessions/{id}/profile/{profile_id}",
|
||||
routing::put(import::api_apply_profile),
|
||||
)
|
||||
.route(
|
||||
"/profile",
|
||||
routing::get(users::get_profile).put(users::update_profile_handler),
|
||||
)
|
||||
.route(
|
||||
"/profile/fields",
|
||||
routing::put(users::update_profile_fields_handler),
|
||||
)
|
||||
.route("/search", routing::get(search::get_search))
|
||||
.route("/people/{id}", routing::get(search::get_person_handler))
|
||||
.route(
|
||||
"/people/{id}/credits",
|
||||
routing::get(search::get_person_credits_handler),
|
||||
)
|
||||
.route(
|
||||
"/watchlist",
|
||||
routing::get(watchlist::get_watchlist_handler).post(watchlist::post_watchlist_add),
|
||||
)
|
||||
.route(
|
||||
"/watchlist/{movie_id}",
|
||||
routing::get(watchlist::get_watchlist_status).delete(watchlist::delete_watchlist_entry),
|
||||
)
|
||||
.route(
|
||||
"/settings/webhook-tokens",
|
||||
routing::get(webhook::get_webhook_tokens).post(webhook::post_generate_webhook_token),
|
||||
)
|
||||
.route(
|
||||
"/settings/webhook-tokens/{id}",
|
||||
routing::delete(webhook::delete_webhook_token),
|
||||
)
|
||||
.route("/watch-queue", routing::get(webhook::get_watch_queue))
|
||||
.route(
|
||||
"/watch-queue/confirm",
|
||||
routing::post(webhook::post_confirm_watch_events),
|
||||
)
|
||||
.route(
|
||||
"/watch-queue/dismiss",
|
||||
routing::post(webhook::post_dismiss_watch_events),
|
||||
)
|
||||
.route("/wrapups/generate", routing::post(wrapup::post_generate))
|
||||
.route("/wrapups", routing::get(wrapup::get_list))
|
||||
.route(
|
||||
"/wrapups/{id}",
|
||||
routing::get(wrapup::get_status).delete(wrapup::delete_wrapup_handler),
|
||||
)
|
||||
.route("/wrapups/{id}/report", routing::get(wrapup::get_report))
|
||||
.route("/insights", routing::get(insights::get_insights))
|
||||
.route(
|
||||
"/admin/reindex-search",
|
||||
routing::post(search::post_reindex_search),
|
||||
)
|
||||
.route(
|
||||
"/goals",
|
||||
routing::get(goals::list_goals).post(goals::create_goal),
|
||||
)
|
||||
.route(
|
||||
"/goals/{year}",
|
||||
routing::put(goals::update_goal).delete(goals::delete_goal),
|
||||
)
|
||||
.route("/users/{id}/goals", routing::get(goals::get_user_goals))
|
||||
.route(
|
||||
"/settings",
|
||||
routing::get(goals::get_settings).put(goals::update_settings),
|
||||
);
|
||||
|
||||
base.merge(social_routes())
|
||||
}
|
||||
|
||||
/// Media-server webhook ingestion endpoints (mounted under `/api/v1`).
|
||||
pub fn webhook_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/webhooks/jellyfin",
|
||||
routing::post(webhook::post_jellyfin_webhook),
|
||||
)
|
||||
.route("/webhooks/plex", routing::post(webhook::post_plex_webhook))
|
||||
}
|
||||
|
||||
fn social_routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/social/following", routing::get(social::get_following))
|
||||
.route("/social/followers", routing::get(social::get_followers))
|
||||
.route(
|
||||
"/social/followers/pending",
|
||||
routing::get(social::get_pending_followers),
|
||||
)
|
||||
.route(
|
||||
"/social/followers/pending/count",
|
||||
routing::get(social::get_pending_follower_count),
|
||||
)
|
||||
.route(
|
||||
"/social/following/pending",
|
||||
routing::get(social::get_pending_following),
|
||||
)
|
||||
.route(
|
||||
"/social/relationship",
|
||||
routing::get(social::get_relationship),
|
||||
)
|
||||
.route("/social/follow", routing::post(social::follow))
|
||||
.route("/social/unfollow", routing::post(social::unfollow))
|
||||
.route(
|
||||
"/social/followers/accept",
|
||||
routing::post(social::accept_follower),
|
||||
)
|
||||
.route(
|
||||
"/social/followers/reject",
|
||||
routing::post(social::reject_follower),
|
||||
)
|
||||
.route(
|
||||
"/social/followers/remove",
|
||||
routing::post(social::remove_follower),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/following",
|
||||
routing::get(social::get_user_following),
|
||||
)
|
||||
.route(
|
||||
"/users/{id}/followers",
|
||||
routing::get(social::get_user_followers),
|
||||
)
|
||||
}
|
||||
249
crates/adapters/http-axum/src/api/movies.rs
Normal file
249
crates/adapters/http-axum/src/api/movies.rs
Normal file
@@ -0,0 +1,249 @@
|
||||
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
|
||||
use api_types::{
|
||||
CastMemberDto, CrewMemberDto, GenreDto, KeywordDto, MovieDetailResponse, MovieProfileResponse,
|
||||
MovieStatsDto, MoviesQueryParams, MoviesResponse, PaginationQueryParams, ReviewHistoryResponse,
|
||||
SocialFeedResponse, SocialReviewDto,
|
||||
};
|
||||
use application::{
|
||||
diary::{
|
||||
commands::SyncPosterCommand,
|
||||
get_movie_social_page, get_review_history,
|
||||
queries::{GetMovieSocialPageQuery, GetReviewHistoryQuery},
|
||||
},
|
||||
movies::{get_movies, queries::GetMoviesQuery, sync_poster},
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use domain::services::review_history::Trend;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/movies",
|
||||
params(MoviesQueryParams),
|
||||
responses(
|
||||
(status = 200, body = MoviesResponse),
|
||||
)
|
||||
)]
|
||||
pub async fn list_movies(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<MoviesQueryParams>,
|
||||
) -> Result<Json<MoviesResponse>, ApiError> {
|
||||
let page = get_movies::execute(
|
||||
&state.app_ctx.deps.movies.get_movies,
|
||||
GetMoviesQuery {
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
search: params.search,
|
||||
genre: params.genre,
|
||||
language: params.language,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(MoviesResponse {
|
||||
items: page
|
||||
.items
|
||||
.iter()
|
||||
.map(api_types::mappers::summary_to_dto)
|
||||
.collect(),
|
||||
total_count: page.total_count,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/movies/{id}/history",
|
||||
params(("id" = Uuid, Path, description = "Movie ID")),
|
||||
responses(
|
||||
(status = 200, body = ReviewHistoryResponse),
|
||||
(status = 404, description = "Movie not found"),
|
||||
)
|
||||
)]
|
||||
pub async fn get_review_history(
|
||||
State(state): State<AppState>,
|
||||
Path(movie_id): Path<Uuid>,
|
||||
) -> Result<Json<ReviewHistoryResponse>, ApiError> {
|
||||
let (history, trend) = get_review_history::execute(
|
||||
&state.app_ctx.deps.diary.get_review_history,
|
||||
GetReviewHistoryQuery { movie_id },
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ReviewHistoryResponse {
|
||||
movie: api_types::mappers::movie_to_dto(history.movie()),
|
||||
viewings: history
|
||||
.viewings()
|
||||
.iter()
|
||||
.map(api_types::mappers::review_to_dto)
|
||||
.collect(),
|
||||
trend: match trend {
|
||||
Trend::Improved => "improved",
|
||||
Trend::Declined => "declined",
|
||||
Trend::Neutral => "neutral",
|
||||
}
|
||||
.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/movies/{id}/sync-poster",
|
||||
params(("id" = Uuid, Path, description = "Movie ID")),
|
||||
responses(
|
||||
(status = 204, description = "Poster synced"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Movie not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn sync_poster(
|
||||
State(state): State<AppState>,
|
||||
_user: AuthenticatedUser,
|
||||
Path(movie_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
sync_poster::execute(
|
||||
&state.app_ctx.deps.movies.sync_poster,
|
||||
SyncPosterCommand { movie_id },
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/movies/{movie_id}",
|
||||
params(("movie_id" = Uuid, Path, description = "Movie ID")),
|
||||
responses(
|
||||
(status = 200, body = MovieDetailResponse),
|
||||
(status = 404, description = "Movie not found"),
|
||||
)
|
||||
)]
|
||||
pub async fn get_movie_detail(
|
||||
State(state): State<AppState>,
|
||||
Path(movie_id): Path<Uuid>,
|
||||
Query(params): Query<PaginationQueryParams>,
|
||||
) -> Result<Json<MovieDetailResponse>, ApiError> {
|
||||
let limit = params.limit.unwrap_or(20);
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
|
||||
let result = get_movie_social_page::execute(
|
||||
&state.app_ctx.deps.diary.get_movie_social_page,
|
||||
GetMovieSocialPageQuery {
|
||||
movie_id,
|
||||
limit,
|
||||
offset,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(MovieDetailResponse {
|
||||
movie: api_types::mappers::movie_to_dto(&result.movie),
|
||||
stats: MovieStatsDto {
|
||||
total_count: result.stats.total_count,
|
||||
avg_rating: result.stats.avg_rating,
|
||||
federated_count: result.stats.federated_count,
|
||||
rating_histogram: result.stats.rating_histogram,
|
||||
},
|
||||
reviews: SocialFeedResponse {
|
||||
items: result
|
||||
.reviews
|
||||
.items
|
||||
.iter()
|
||||
.map(|e| SocialReviewDto {
|
||||
user_display: e.user_display_name().to_string(),
|
||||
user_id: e.review().user_id().value(),
|
||||
rating: e.review().rating().value(),
|
||||
comment: e.review().comment().map(|c| c.value().to_string()),
|
||||
watched_at: domain::value_objects::format_watched_at(e.review().watched_at()),
|
||||
is_federated: e.review().is_remote(),
|
||||
watch_medium: e.review().watch_medium().copied(),
|
||||
spoilers: e.review().spoilers(),
|
||||
})
|
||||
.collect(),
|
||||
total_count: result.reviews.total_count,
|
||||
limit: result.reviews.limit,
|
||||
offset: result.reviews.offset,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/movies/{id}/profile",
|
||||
params(("id" = Uuid, Path, description = "Movie ID")),
|
||||
responses(
|
||||
(status = 200, body = MovieProfileResponse),
|
||||
(status = 404, description = "No profile found for this movie"),
|
||||
)
|
||||
)]
|
||||
pub async fn get_movie_profile(
|
||||
State(state): State<AppState>,
|
||||
Path(movie_id): Path<Uuid>,
|
||||
) -> impl IntoResponse {
|
||||
use application::movies::get_movie_profile;
|
||||
let query = get_movie_profile::GetMovieProfileQuery { movie_id };
|
||||
match get_movie_profile::execute(&state.app_ctx.deps.movies.get_movie_profile, query).await {
|
||||
Ok(Some(result)) => {
|
||||
let p = result.profile;
|
||||
Json(MovieProfileResponse {
|
||||
tmdb_id: p.tmdb_id,
|
||||
imdb_id: p.imdb_id,
|
||||
overview: p.overview,
|
||||
tagline: p.tagline,
|
||||
runtime_minutes: p.runtime_minutes,
|
||||
budget_usd: p.budget_usd,
|
||||
revenue_usd: p.revenue_usd,
|
||||
vote_average: p.vote_average,
|
||||
vote_count: p.vote_count,
|
||||
original_language: p.original_language,
|
||||
collection_name: p.collection_name,
|
||||
genres: p
|
||||
.genres
|
||||
.into_iter()
|
||||
.map(|g| GenreDto {
|
||||
tmdb_id: g.tmdb_id,
|
||||
name: g.name,
|
||||
})
|
||||
.collect(),
|
||||
keywords: p
|
||||
.keywords
|
||||
.into_iter()
|
||||
.map(|k| KeywordDto {
|
||||
tmdb_id: k.tmdb_id,
|
||||
name: k.name,
|
||||
})
|
||||
.collect(),
|
||||
cast: result
|
||||
.cast
|
||||
.into_iter()
|
||||
.map(|c| CastMemberDto {
|
||||
person_id: c.person_id.value().to_string(),
|
||||
tmdb_person_id: c.tmdb_person_id,
|
||||
name: c.name,
|
||||
character: c.character,
|
||||
billing_order: c.billing_order,
|
||||
profile_path: c.profile_path,
|
||||
})
|
||||
.collect(),
|
||||
crew: result
|
||||
.crew
|
||||
.into_iter()
|
||||
.map(|c| CrewMemberDto {
|
||||
person_id: c.person_id.value().to_string(),
|
||||
tmdb_person_id: c.tmdb_person_id,
|
||||
name: c.name,
|
||||
job: c.job,
|
||||
department: c.department,
|
||||
profile_path: c.profile_path,
|
||||
})
|
||||
.collect(),
|
||||
enriched_at: p.enriched_at.to_rfc3339(),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Ok(None) => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(e) => crate::errors::domain_error_response(e),
|
||||
}
|
||||
}
|
||||
174
crates/adapters/http-axum/src/api/search.rs
Normal file
174
crates/adapters/http-axum/src/api/search.rs
Normal file
@@ -0,0 +1,174 @@
|
||||
use crate::state::AppState;
|
||||
use api_types::search::{
|
||||
CastCreditDto, CrewCreditDto, MovieSearchHitDto, PaginatedMovieHits, PaginatedPersonHits,
|
||||
PersonCreditsDto, PersonSearchHitDto, SearchQueryParams, SearchResponse,
|
||||
};
|
||||
use application::{
|
||||
person::{get as get_person, get_credits as get_person_credits},
|
||||
search::execute as search_uc,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use domain::models::{PersonId, collections::PageParams};
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/search",
|
||||
params(api_types::search::SearchQueryParams),
|
||||
responses(
|
||||
(status = 200, body = api_types::search::SearchResponse),
|
||||
),
|
||||
tag = "search",
|
||||
)]
|
||||
pub async fn get_search(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<SearchQueryParams>,
|
||||
) -> impl IntoResponse {
|
||||
let query = domain::models::SearchQuery {
|
||||
text: params.q,
|
||||
filters: domain::models::SearchFilters {
|
||||
genre: params.genre,
|
||||
year: params.year,
|
||||
person_id: params.person_id.map(PersonId::from_uuid),
|
||||
department: params.department,
|
||||
language: params.language,
|
||||
},
|
||||
page: PageParams {
|
||||
limit: params.limit.unwrap_or(5),
|
||||
offset: params.offset.unwrap_or(0),
|
||||
},
|
||||
};
|
||||
|
||||
match search_uc::execute(&state.app_ctx.deps.search.execute, query).await {
|
||||
Ok(results) => axum::Json(SearchResponse {
|
||||
movies: PaginatedMovieHits {
|
||||
items: results
|
||||
.movies
|
||||
.items
|
||||
.iter()
|
||||
.map(|h| MovieSearchHitDto {
|
||||
movie_id: h.movie_id.value(),
|
||||
title: h.title.clone(),
|
||||
release_year: h.release_year,
|
||||
director: h.director.clone(),
|
||||
poster_path: h.poster_path.clone(),
|
||||
genres: h.genres.clone(),
|
||||
})
|
||||
.collect(),
|
||||
total_count: results.movies.total_count,
|
||||
limit: results.movies.limit,
|
||||
offset: results.movies.offset,
|
||||
},
|
||||
people: PaginatedPersonHits {
|
||||
items: results
|
||||
.people
|
||||
.items
|
||||
.iter()
|
||||
.map(|h| PersonSearchHitDto {
|
||||
person_id: h.person_id.value(),
|
||||
name: h.name.clone(),
|
||||
known_for_department: h.known_for_department.clone(),
|
||||
profile_path: h.profile_path.clone(),
|
||||
known_for_titles: h.known_for_titles.clone(),
|
||||
})
|
||||
.collect(),
|
||||
total_count: results.people.total_count,
|
||||
limit: results.people.limit,
|
||||
offset: results.people.offset,
|
||||
},
|
||||
})
|
||||
.into_response(),
|
||||
Err(e) => crate::errors::domain_error_response(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/people/{id}",
|
||||
params(("id" = Uuid, Path, description = "Person ID")),
|
||||
responses(
|
||||
(status = 200, body = api_types::search::PersonDto),
|
||||
(status = 404, description = "Person not found"),
|
||||
),
|
||||
tag = "search",
|
||||
)]
|
||||
pub async fn get_person_handler(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> impl IntoResponse {
|
||||
match get_person::execute(
|
||||
&state.app_ctx.deps.person.get_person,
|
||||
PersonId::from_uuid(id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(person)) => axum::Json(api_types::mappers::person_to_dto(&person)).into_response(),
|
||||
Ok(None) => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(e) => crate::errors::domain_error_response(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/people/{id}/credits",
|
||||
params(("id" = Uuid, Path, description = "Person ID")),
|
||||
responses(
|
||||
(status = 200, body = api_types::search::PersonCreditsDto),
|
||||
(status = 404, description = "Person not found"),
|
||||
),
|
||||
tag = "search",
|
||||
)]
|
||||
pub async fn get_person_credits_handler(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> impl IntoResponse {
|
||||
match get_person_credits::execute(
|
||||
&state.app_ctx.deps.person.get_person,
|
||||
PersonId::from_uuid(id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(credits) => axum::Json(PersonCreditsDto {
|
||||
person: api_types::mappers::person_to_dto(&credits.person),
|
||||
cast: credits
|
||||
.cast
|
||||
.iter()
|
||||
.map(|c| CastCreditDto {
|
||||
movie_id: c.movie_id.value(),
|
||||
title: c.title.clone(),
|
||||
release_year: c.release_year,
|
||||
character: c.character.clone(),
|
||||
poster_path: c.poster_path.clone(),
|
||||
})
|
||||
.collect(),
|
||||
crew: credits
|
||||
.crew
|
||||
.iter()
|
||||
.map(|c| CrewCreditDto {
|
||||
movie_id: c.movie_id.value(),
|
||||
title: c.title.clone(),
|
||||
release_year: c.release_year,
|
||||
job: c.job.clone(),
|
||||
department: c.department.clone(),
|
||||
poster_path: c.poster_path.clone(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.into_response(),
|
||||
Err(e) => crate::errors::domain_error_response(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_reindex_search(
|
||||
State(state): State<AppState>,
|
||||
_admin: crate::extractors::AdminApiUser,
|
||||
) -> impl IntoResponse {
|
||||
let event = domain::events::DomainEvent::SearchReindexRequested;
|
||||
match state.app_ctx.services.event_publisher.publish(&event).await {
|
||||
Ok(()) => StatusCode::ACCEPTED,
|
||||
Err(e) => {
|
||||
tracing::error!("failed to publish reindex event: {:?}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
348
crates/adapters/http-axum/src/api/social.rs
Normal file
348
crates/adapters/http-axum/src/api/social.rs
Normal file
@@ -0,0 +1,348 @@
|
||||
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
|
||||
use api_types::{
|
||||
ActorListResponse, ActorUrlRequest, FollowRelationResponse, FollowRequest,
|
||||
PendingCountResponse, RemoteActorDto,
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use domain::value_objects::{FollowTarget, InstanceIdentity, SocialActor, SocialIdentity};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn social_actor_to_dto(actor: SocialActor, instance: &InstanceIdentity) -> RemoteActorDto {
|
||||
RemoteActorDto {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/social/following",
|
||||
responses(
|
||||
(status = 200, body = ActorListResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_following(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<ActorListResponse>, ApiError> {
|
||||
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(|a| social_actor_to_dto(a, &instance))
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/social/followers",
|
||||
responses(
|
||||
(status = 200, body = ActorListResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_followers(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<ActorListResponse>, ApiError> {
|
||||
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(|a| social_actor_to_dto(a, &instance))
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_user_following(
|
||||
State(state): State<AppState>,
|
||||
_user: AuthenticatedUser,
|
||||
Path(user_id): Path<Uuid>,
|
||||
) -> Result<Json<ActorListResponse>, ApiError> {
|
||||
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(|a| social_actor_to_dto(a, &instance))
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_user_followers(
|
||||
State(state): State<AppState>,
|
||||
_user: AuthenticatedUser,
|
||||
Path(user_id): Path<Uuid>,
|
||||
) -> Result<Json<ActorListResponse>, ApiError> {
|
||||
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(|a| social_actor_to_dto(a, &instance))
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/social/follow",
|
||||
request_body = FollowRequest,
|
||||
responses(
|
||||
(status = 200, description = "Follow request sent"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn follow(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<FollowRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
application::social::execute::execute_command(
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::Follow {
|
||||
follower_id: user.0.value(),
|
||||
target: FollowTarget::Handle(body.handle),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/social/unfollow",
|
||||
request_body = ActorUrlRequest,
|
||||
responses(
|
||||
(status = 200, description = "Unfollowed"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn unfollow(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
application::social::execute::execute_command(
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::Unfollow {
|
||||
follower_id: user.0.value(),
|
||||
target: instance.identify(&body.actor_url),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/social/followers/accept",
|
||||
request_body = ActorUrlRequest,
|
||||
responses(
|
||||
(status = 200, description = "Follower accepted"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn accept_follower(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
application::social::execute::execute_command(
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::AcceptFollow {
|
||||
owner_id: user.0.value(),
|
||||
requester: instance.identify(&body.actor_url),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/social/followers/reject",
|
||||
request_body = ActorUrlRequest,
|
||||
responses(
|
||||
(status = 200, description = "Follower rejected"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn reject_follower(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
application::social::execute::execute_command(
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::RejectFollow {
|
||||
owner_id: user.0.value(),
|
||||
requester: instance.identify(&body.actor_url),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/social/followers/remove",
|
||||
request_body = ActorUrlRequest,
|
||||
responses(
|
||||
(status = 200, description = "Follower removed"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn remove_follower(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(body): Json<ActorUrlRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let instance = state.app_ctx.instance.clone();
|
||||
application::social::execute::execute_command(
|
||||
&state.app_ctx.deps.social.command,
|
||||
application::social::commands::SocialCmd::RemoveFollower {
|
||||
owner_id: user.0.value(),
|
||||
follower: instance.identify(&body.actor_url),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/social/followers/pending",
|
||||
responses(
|
||||
(status = 200, body = ActorListResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_pending_followers(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<ActorListResponse>, ApiError> {
|
||||
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(|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(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
77
crates/adapters/http-axum/src/api/social/tests.rs
Normal file
77
crates/adapters/http-axum/src/api/social/tests.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
279
crates/adapters/http-axum/src/api/user_profile.rs
Normal file
279
crates/adapters/http-axum/src/api/user_profile.rs
Normal file
@@ -0,0 +1,279 @@
|
||||
use crate::{extractors::AuthenticatedUser, state::AppState};
|
||||
use api_types::mappers::goal_with_progress_to_dto;
|
||||
use api_types::{
|
||||
ActorStatDto, DiaryResponse, DirectorStatDto, GenreStatDto, MonthActivityDto, MonthlyRatingDto,
|
||||
UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserTrendsDto, WatchMediumStatDto,
|
||||
};
|
||||
use application::users::{
|
||||
get_federated_profile, get_federated_profile_stats, get_local_profile,
|
||||
queries::GetUserProfileQuery,
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use domain::errors::DomainError;
|
||||
use std::str::FromStr;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/users/{id}",
|
||||
params(
|
||||
("id" = Uuid, Path, description = "User ID"),
|
||||
UserProfileQueryParams,
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = UserProfileResponse),
|
||||
(status = 404, description = "User not found"),
|
||||
)
|
||||
)]
|
||||
pub async fn get_user_profile(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(viewer_id): AuthenticatedUser,
|
||||
Path(user_id): Path<Uuid>,
|
||||
Query(params): Query<UserProfileQueryParams>,
|
||||
) -> impl IntoResponse {
|
||||
let view_str = params.view.as_deref().unwrap_or("recent");
|
||||
let profile_view = match application::users::queries::ProfileView::from_str(view_str) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
|
||||
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.clone(),
|
||||
is_own_profile: viewer_id.value() == user_id,
|
||||
include_remote: false,
|
||||
},
|
||||
)
|
||||
.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),
|
||||
};
|
||||
|
||||
let view_data = if let Some(p) = profile.entries {
|
||||
Some(api_types::ProfileViewData::Entries {
|
||||
entries: DiaryResponse {
|
||||
items: p
|
||||
.items
|
||||
.iter()
|
||||
.map(api_types::mappers::entry_to_dto)
|
||||
.collect(),
|
||||
total_count: p.total_count,
|
||||
limit: p.limit,
|
||||
offset: p.offset,
|
||||
},
|
||||
})
|
||||
} else if let Some(h) = profile.history {
|
||||
Some(api_types::ProfileViewData::History {
|
||||
history: application::users::group_by_month(h)
|
||||
.into_iter()
|
||||
.map(|m| MonthActivityDto {
|
||||
year_month: m.year_month,
|
||||
month_label: m.month_label,
|
||||
count: m.count,
|
||||
entries: m
|
||||
.entries
|
||||
.iter()
|
||||
.map(api_types::mappers::entry_to_dto)
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
} else {
|
||||
profile.trends.map(|t| api_types::ProfileViewData::Trends {
|
||||
trends: trends_to_dto(t),
|
||||
})
|
||||
};
|
||||
|
||||
Json(UserProfileResponse {
|
||||
user_id,
|
||||
profile: api_types::UserProfileBase {
|
||||
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,
|
||||
avg_rating: profile.stats.avg_rating,
|
||||
favorite_director: profile.stats.favorite_director,
|
||||
most_active_month: profile.stats.most_active_month,
|
||||
},
|
||||
following_count: profile.following_count,
|
||||
followers_count: profile.followers_count,
|
||||
view_data,
|
||||
goals: {
|
||||
let goals_list = application::goals::list::execute(
|
||||
&state.app_ctx.deps.goals.query,
|
||||
application::goals::queries::ListGoalsQuery { user_id },
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if goals_list.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(goals_list.iter().map(goal_with_progress_to_dto).collect())
|
||||
}
|
||||
},
|
||||
is_federated: false,
|
||||
handle: Some(profile.identity.handle.clone()),
|
||||
actor_url: Some(profile.identity.actor_url.clone()),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn build_federated_profile_response(
|
||||
state: &AppState,
|
||||
user_id: Uuid,
|
||||
fed: domain::models::FederatedProfile,
|
||||
profile_view: application::users::queries::ProfileView,
|
||||
params: &UserProfileQueryParams,
|
||||
) -> axum::response::Response {
|
||||
let profile = match get_federated_profile_stats::execute(
|
||||
&state.app_ctx.deps.users.get_federated_profile_stats,
|
||||
GetUserProfileQuery {
|
||||
user_id,
|
||||
view: profile_view,
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
sort_by: domain::models::FeedSortBy::Date,
|
||||
search: params.search.clone(),
|
||||
is_own_profile: false,
|
||||
include_remote: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(e) => return crate::errors::domain_error_response(e),
|
||||
};
|
||||
|
||||
let view_data = if let Some(p) = profile.entries {
|
||||
Some(api_types::ProfileViewData::Entries {
|
||||
entries: DiaryResponse {
|
||||
items: p
|
||||
.items
|
||||
.iter()
|
||||
.map(api_types::mappers::entry_to_dto)
|
||||
.collect(),
|
||||
total_count: p.total_count,
|
||||
limit: p.limit,
|
||||
offset: p.offset,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
profile.trends.map(|t| api_types::ProfileViewData::Trends {
|
||||
trends: trends_to_dto(t),
|
||||
})
|
||||
};
|
||||
|
||||
let username = fed
|
||||
.display_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| fed.handle.clone());
|
||||
|
||||
Json(UserProfileResponse {
|
||||
user_id,
|
||||
profile: api_types::UserProfileBase {
|
||||
username,
|
||||
avatar_url: fed.avatar_url,
|
||||
banner_url: fed.banner_url,
|
||||
display_name: fed.display_name,
|
||||
bio: fed.bio,
|
||||
},
|
||||
stats: UserStatsDto {
|
||||
total_movies: profile.stats.total_movies,
|
||||
avg_rating: profile.stats.avg_rating,
|
||||
favorite_director: profile.stats.favorite_director,
|
||||
most_active_month: profile.stats.most_active_month,
|
||||
},
|
||||
following_count: 0,
|
||||
followers_count: 0,
|
||||
view_data,
|
||||
goals: None,
|
||||
is_federated: true,
|
||||
handle: Some(fed.handle),
|
||||
actor_url: Some(fed.actor_url),
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn trends_to_dto(t: domain::models::UserTrends) -> UserTrendsDto {
|
||||
UserTrendsDto {
|
||||
monthly_ratings: t
|
||||
.monthly_ratings
|
||||
.into_iter()
|
||||
.map(|r| MonthlyRatingDto {
|
||||
year_month: r.year_month,
|
||||
month_label: r.month_label,
|
||||
avg_rating: r.avg_rating,
|
||||
count: r.count,
|
||||
})
|
||||
.collect(),
|
||||
top_directors: t
|
||||
.top_directors
|
||||
.into_iter()
|
||||
.map(|d| DirectorStatDto {
|
||||
director: d.director,
|
||||
count: d.count,
|
||||
})
|
||||
.collect(),
|
||||
max_director_count: t.max_director_count,
|
||||
top_genres: t
|
||||
.top_genres
|
||||
.into_iter()
|
||||
.map(|g| GenreStatDto {
|
||||
genre: g.genre,
|
||||
count: g.count,
|
||||
})
|
||||
.collect(),
|
||||
rating_distribution: t.rating_distribution,
|
||||
watch_medium_distribution: t
|
||||
.watch_medium_distribution
|
||||
.into_iter()
|
||||
.map(|m| WatchMediumStatDto {
|
||||
medium: m.medium,
|
||||
count: m.count,
|
||||
})
|
||||
.collect(),
|
||||
top_actors: t
|
||||
.top_actors
|
||||
.into_iter()
|
||||
.map(|a| ActorStatDto {
|
||||
name: a.name,
|
||||
profile_path: a.profile_path,
|
||||
movie_count: a.movie_count,
|
||||
score: a.score,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
152
crates/adapters/http-axum/src/api/users.rs
Normal file
152
crates/adapters/http-axum/src/api/users.rs
Normal file
@@ -0,0 +1,152 @@
|
||||
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
|
||||
use api_types::{ProfileResponse, UserSummaryDto, UsersResponse};
|
||||
use application::users::{
|
||||
get_users, queries::GetUsersQuery, update_profile, update_profile_fields,
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Multipart, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/profile",
|
||||
responses(
|
||||
(status = 200, body = ProfileResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "User not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_profile(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
) -> Result<Json<ProfileResponse>, ApiError> {
|
||||
let profile = application::users::get_current_profile::execute(
|
||||
&state.app_ctx.deps.users.get_current_profile,
|
||||
application::users::queries::GetCurrentProfileQuery {
|
||||
user_id: user_id.value(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
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| 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
|
||||
.fields
|
||||
.into_iter()
|
||||
.map(|f| api_types::ProfileFieldDto {
|
||||
name: f.name,
|
||||
value: f.value,
|
||||
})
|
||||
.collect(),
|
||||
role: profile.role,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put, path = "/api/v1/profile",
|
||||
responses(
|
||||
(status = 204, description = "Profile updated"),
|
||||
(status = 400, description = "Invalid input"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn update_profile_handler(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
multipart: Multipart,
|
||||
) -> impl IntoResponse {
|
||||
let data = crate::support::parse_profile_multipart(multipart).await;
|
||||
|
||||
let cmd = application::users::commands::UpdateProfileCommand {
|
||||
user_id: user_id.value(),
|
||||
display_name: data.display_name,
|
||||
bio: data.bio,
|
||||
avatar_bytes: data.avatar_bytes,
|
||||
avatar_content_type: data.avatar_content_type,
|
||||
banner_bytes: data.banner_bytes,
|
||||
banner_content_type: data.banner_content_type,
|
||||
also_known_as: data.also_known_as,
|
||||
};
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put, path = "/api/v1/profile/fields",
|
||||
request_body = api_types::UpdateProfileFieldsRequest,
|
||||
responses(
|
||||
(status = 204, description = "Profile fields updated"),
|
||||
(status = 400, description = "Invalid input"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn update_profile_fields_handler(
|
||||
State(state): State<AppState>,
|
||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||
axum::Json(body): axum::Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
let raw_fields = match body.get("fields").and_then(|f| f.as_array()) {
|
||||
Some(arr) => arr.clone(),
|
||||
None => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
|
||||
let fields: Vec<domain::models::ProfileField> = raw_fields
|
||||
.iter()
|
||||
.filter_map(|f| {
|
||||
let name = f.get("name").and_then(|n| n.as_str())?.to_string();
|
||||
let value = f.get("value").and_then(|v| v.as_str())?.to_string();
|
||||
Some(domain::models::ProfileField { name, value })
|
||||
})
|
||||
.collect();
|
||||
|
||||
let cmd = application::users::commands::UpdateProfileFieldsCommand {
|
||||
user_id: user_id.value(),
|
||||
fields,
|
||||
};
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/users",
|
||||
responses((status = 200, body = UsersResponse)),
|
||||
)]
|
||||
pub async fn list_users(State(state): State<AppState>) -> Result<Json<UsersResponse>, ApiError> {
|
||||
let result =
|
||||
get_users::execute(&state.app_ctx.deps.users.get_users_list, GetUsersQuery).await?;
|
||||
Ok(Json(UsersResponse {
|
||||
users: result
|
||||
.users
|
||||
.iter()
|
||||
.map(|u| UserSummaryDto {
|
||||
id: u.user_id.value(),
|
||||
email: u.email().to_string(),
|
||||
username: u.username().to_string(),
|
||||
display_name: u.display_name().map(String::from),
|
||||
total_movies: u.total_movies,
|
||||
avg_rating: u.avg_rating,
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
148
crates/adapters/http-axum/src/api/watchlist.rs
Normal file
148
crates/adapters/http-axum/src/api/watchlist.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
|
||||
use api_types::{
|
||||
AddToWatchlistRequest, PaginationQueryParams, WatchlistEntryDto, WatchlistResponse,
|
||||
WatchlistStatusResponse,
|
||||
};
|
||||
use application::{
|
||||
diary::commands::MovieInput,
|
||||
watchlist::{
|
||||
add as add_to_watchlist,
|
||||
commands::{AddToWatchlistCommand, RemoveFromWatchlistCommand},
|
||||
get as get_watchlist, is_on as is_on_watchlist,
|
||||
queries::{GetWatchlistQuery, IsOnWatchlistQuery},
|
||||
remove as remove_from_watchlist,
|
||||
},
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/watchlist",
|
||||
params(
|
||||
("limit" = Option<u32>, Query, description = "Max results"),
|
||||
("offset" = Option<u32>, Query, description = "Offset"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = WatchlistResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_watchlist_handler(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Query(params): Query<PaginationQueryParams>,
|
||||
) -> Result<Json<WatchlistResponse>, ApiError> {
|
||||
let page = get_watchlist::execute(
|
||||
&state.app_ctx.deps.watchlist.get_watchlist,
|
||||
GetWatchlistQuery {
|
||||
user_id: user.0.value(),
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(WatchlistResponse {
|
||||
items: page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|w| WatchlistEntryDto {
|
||||
id: w.entry.id.value(),
|
||||
movie: api_types::mappers::movie_to_dto(&w.movie),
|
||||
added_at: w.entry.added_at.to_string(),
|
||||
})
|
||||
.collect(),
|
||||
total_count: page.total_count,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/watchlist",
|
||||
request_body = AddToWatchlistRequest,
|
||||
responses(
|
||||
(status = 201, description = "Added to watchlist"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Movie not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn post_watchlist_add(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(req): Json<AddToWatchlistRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
add_to_watchlist::execute(
|
||||
&state.app_ctx.deps.watchlist.add,
|
||||
AddToWatchlistCommand {
|
||||
user_id: user.0.value(),
|
||||
input: MovieInput {
|
||||
movie_id: req.movie_id,
|
||||
external_metadata_id: req.external_metadata_id,
|
||||
manual_title: req.manual_title,
|
||||
manual_release_year: req.manual_release_year,
|
||||
manual_director: None,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/v1/watchlist/{movie_id}",
|
||||
params(("movie_id" = Uuid, Path, description = "Movie ID")),
|
||||
responses(
|
||||
(status = 204, description = "Removed from watchlist"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Not on watchlist"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_watchlist_entry(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Path(movie_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
remove_from_watchlist::execute(
|
||||
&state.app_ctx.deps.watchlist.remove_from_watchlist,
|
||||
RemoveFromWatchlistCommand {
|
||||
user_id: user.0.value(),
|
||||
movie_id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/watchlist/{movie_id}",
|
||||
params(("movie_id" = Uuid, Path, description = "Movie ID")),
|
||||
responses(
|
||||
(status = 200, body = WatchlistStatusResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_watchlist_status(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Path(movie_id): Path<Uuid>,
|
||||
) -> Result<Json<WatchlistStatusResponse>, ApiError> {
|
||||
let on_watchlist = is_on_watchlist::execute(
|
||||
&state.app_ctx.deps.watchlist.is_on_watchlist,
|
||||
IsOnWatchlistQuery {
|
||||
user_id: user.0.value(),
|
||||
movie_id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(WatchlistStatusResponse { on_watchlist }))
|
||||
}
|
||||
329
crates/adapters/http-axum/src/api/webhook.rs
Normal file
329
crates/adapters/http-axum/src/api/webhook.rs
Normal file
@@ -0,0 +1,329 @@
|
||||
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
|
||||
use api_types::{
|
||||
ConfirmWatchRequest, ConfirmWatchResponse, DismissWatchRequest, DismissWatchResponse,
|
||||
GenerateTokenRequest, GenerateTokenResponse, WatchQueueEntryDto, WebhookTokenDto,
|
||||
};
|
||||
use application::integrations::{
|
||||
commands::{
|
||||
ConfirmWatchEventsCommand, DismissWatchEventsCommand, GenerateWebhookTokenCommand,
|
||||
IngestWatchEventCommand, RevokeWebhookTokenCommand, WatchEventConfirmation,
|
||||
},
|
||||
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,
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Multipart, Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use domain::models::WatchEventSource;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn extract_bearer_token(headers: &HeaderMap) -> Option<String> {
|
||||
headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.map(|t| t.trim().to_string())
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Default)]
|
||||
pub struct WebhookTokenQuery {
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
fn extract_webhook_token(headers: &HeaderMap, query: &WebhookTokenQuery) -> Option<String> {
|
||||
extract_bearer_token(headers).or_else(|| query.token.clone())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/webhooks/jellyfin",
|
||||
request_body(content = String, description = "Jellyfin webhook JSON payload (SendAllProperties=true)", content_type = "application/json"),
|
||||
responses(
|
||||
(status = 200, description = "Event accepted or ignored"),
|
||||
(status = 400, description = "Invalid payload"),
|
||||
(status = 401, description = "Invalid or missing webhook token"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn post_jellyfin_webhook(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
axum::extract::Query(query): axum::extract::Query<WebhookTokenQuery>,
|
||||
body: axum::body::Bytes,
|
||||
) -> impl IntoResponse {
|
||||
let token = match extract_webhook_token(&headers, &query) {
|
||||
Some(t) => t,
|
||||
None => return StatusCode::UNAUTHORIZED,
|
||||
};
|
||||
|
||||
let cmd = IngestWatchEventCommand {
|
||||
token,
|
||||
raw_payload: body.to_vec(),
|
||||
source: WatchEventSource::Jellyfin,
|
||||
};
|
||||
|
||||
run_ingest(
|
||||
&state,
|
||||
cmd,
|
||||
&*state.app_ctx.deps.integrations.jellyfin_parser,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/webhooks/plex",
|
||||
request_body(content = String, description = "Plex webhook multipart form (payload field contains JSON)", content_type = "multipart/form-data"),
|
||||
responses(
|
||||
(status = 200, description = "Event accepted or ignored"),
|
||||
(status = 400, description = "Invalid payload"),
|
||||
(status = 401, description = "Invalid or missing webhook token"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn post_plex_webhook(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
axum::extract::Query(query): axum::extract::Query<WebhookTokenQuery>,
|
||||
mut multipart: Multipart,
|
||||
) -> impl IntoResponse {
|
||||
let token = match extract_webhook_token(&headers, &query) {
|
||||
Some(t) => t,
|
||||
None => return StatusCode::UNAUTHORIZED,
|
||||
};
|
||||
|
||||
let mut payload_bytes: Option<Vec<u8>> = None;
|
||||
while let Ok(Some(field)) = multipart.next_field().await {
|
||||
if field.name() == Some("payload")
|
||||
&& let Ok(bytes) = field.bytes().await
|
||||
{
|
||||
payload_bytes = Some(bytes.to_vec());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let raw_payload = match payload_bytes {
|
||||
Some(b) => b,
|
||||
None => return StatusCode::BAD_REQUEST,
|
||||
};
|
||||
|
||||
let cmd = IngestWatchEventCommand {
|
||||
token,
|
||||
raw_payload,
|
||||
source: WatchEventSource::Plex,
|
||||
};
|
||||
|
||||
run_ingest(&state, cmd, &*state.app_ctx.deps.integrations.plex_parser).await
|
||||
}
|
||||
|
||||
async fn run_ingest(
|
||||
state: &AppState,
|
||||
cmd: IngestWatchEventCommand,
|
||||
parser: &dyn domain::ports::MediaServerParser,
|
||||
) -> StatusCode {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/settings/webhook-tokens",
|
||||
request_body = GenerateTokenRequest,
|
||||
responses(
|
||||
(status = 200, body = GenerateTokenResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn post_generate_webhook_token(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(req): Json<GenerateTokenRequest>,
|
||||
) -> Result<Json<GenerateTokenResponse>, ApiError> {
|
||||
let provider: WatchEventSource = req
|
||||
.provider
|
||||
.parse()
|
||||
.map_err(|e: String| domain::errors::DomainError::ValidationError(e))?;
|
||||
|
||||
let cmd = GenerateWebhookTokenCommand {
|
||||
user_id: user.0.value(),
|
||||
provider: provider.clone(),
|
||||
label: req.label,
|
||||
};
|
||||
|
||||
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}");
|
||||
|
||||
Ok(Json(GenerateTokenResponse {
|
||||
id: result.token.id().value().to_string(),
|
||||
token: result.token_plaintext,
|
||||
webhook_url,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/settings/webhook-tokens",
|
||||
responses(
|
||||
(status = 200, body = Vec<WebhookTokenDto>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_webhook_tokens(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<Vec<WebhookTokenDto>>, ApiError> {
|
||||
let query = GetWebhookTokensQuery {
|
||||
user_id: user.0.value(),
|
||||
};
|
||||
let tokens =
|
||||
get_webhook_tokens::execute(&state.app_ctx.deps.integrations.get_webhook_tokens, query)
|
||||
.await?;
|
||||
|
||||
let dtos = tokens
|
||||
.into_iter()
|
||||
.map(|t| WebhookTokenDto {
|
||||
id: t.id().value().to_string(),
|
||||
provider: t.provider().to_string(),
|
||||
label: t.label().map(String::from),
|
||||
created_at: t.created_at().to_string(),
|
||||
last_used_at: t.last_used_at().map(|d| d.to_string()),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(dtos))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/v1/settings/webhook-tokens/{id}",
|
||||
params(("id" = Uuid, Path, description = "Webhook token ID")),
|
||||
responses(
|
||||
(status = 204, description = "Token revoked"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Token not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_webhook_token(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let cmd = RevokeWebhookTokenCommand {
|
||||
user_id: user.0.value(),
|
||||
token_id: id,
|
||||
};
|
||||
revoke_webhook_token::execute(&state.app_ctx.deps.integrations.revoke_webhook_token, cmd)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/watch-queue",
|
||||
responses(
|
||||
(status = 200, body = Vec<WatchQueueEntryDto>),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_watch_queue(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<Vec<WatchQueueEntryDto>>, ApiError> {
|
||||
let query = GetWatchQueueQuery {
|
||||
user_id: user.0.value(),
|
||||
};
|
||||
let events =
|
||||
get_watch_queue::execute(&state.app_ctx.deps.integrations.get_watch_queue, query).await?;
|
||||
|
||||
let dtos = events
|
||||
.into_iter()
|
||||
.map(|e| WatchQueueEntryDto {
|
||||
id: e.id().value().to_string(),
|
||||
title: e.title().to_string(),
|
||||
year: e.year(),
|
||||
movie_id: e.movie_id().map(|m| m.value().to_string()),
|
||||
source: e.source().to_string(),
|
||||
watched_at: e.watched_at().to_string(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(dtos))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/watch-queue/confirm",
|
||||
request_body = ConfirmWatchRequest,
|
||||
responses(
|
||||
(status = 200, body = ConfirmWatchResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden — not your watch event"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn post_confirm_watch_events(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(req): Json<ConfirmWatchRequest>,
|
||||
) -> Result<Json<ConfirmWatchResponse>, ApiError> {
|
||||
let cmd = ConfirmWatchEventsCommand {
|
||||
user_id: user.0.value(),
|
||||
confirmations: req
|
||||
.confirmations
|
||||
.into_iter()
|
||||
.map(|c| WatchEventConfirmation {
|
||||
watch_event_id: c.watch_event_id,
|
||||
rating: c.rating,
|
||||
comment: c.comment,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let confirmed =
|
||||
confirm_watch_events::execute(&state.app_ctx.deps.integrations.confirm_watch_events, cmd)
|
||||
.await?;
|
||||
Ok(Json(ConfirmWatchResponse { confirmed }))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/watch-queue/dismiss",
|
||||
request_body = DismissWatchRequest,
|
||||
responses(
|
||||
(status = 200, body = DismissWatchResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden — not your watch event"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn post_dismiss_watch_events(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(req): Json<DismissWatchRequest>,
|
||||
) -> Result<Json<DismissWatchResponse>, ApiError> {
|
||||
let cmd = DismissWatchEventsCommand {
|
||||
user_id: user.0.value(),
|
||||
event_ids: req.event_ids,
|
||||
};
|
||||
|
||||
let dismissed =
|
||||
dismiss_watch_events::execute(&state.app_ctx.deps.integrations.dismiss_watch_events, cmd)
|
||||
.await?;
|
||||
Ok(Json(DismissWatchResponse { dismissed }))
|
||||
}
|
||||
176
crates/adapters/http-axum/src/api/wrapup.rs
Normal file
176
crates/adapters/http-axum/src/api/wrapup.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
use crate::{
|
||||
errors::ApiError,
|
||||
extractors::{AdminApiUser, AuthenticatedUser},
|
||||
state::AppState,
|
||||
};
|
||||
use api_types::wrapup::{
|
||||
GenerateWrapUpRequest, WrapUpGeneratedResponse, WrapUpListResponse, WrapUpStatusResponse,
|
||||
};
|
||||
use application::wrapup::{
|
||||
commands::RequestWrapUpCommand,
|
||||
delete as delete_wrapup, generate, get_wrapup,
|
||||
list_wrapups::{self, ListWrapUpsQuery},
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use chrono::NaiveDate;
|
||||
use domain::errors::DomainError;
|
||||
use domain::models::wrapup::{WrapUpRecord, WrapUpStatus};
|
||||
use domain::value_objects::WrapUpId;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn record_to_dto(r: &WrapUpRecord) -> WrapUpStatusResponse {
|
||||
WrapUpStatusResponse {
|
||||
id: r.id.value().to_string(),
|
||||
user_id: r.user_id.map(|u| u.to_string()),
|
||||
status: format!("{:?}", r.status),
|
||||
start_date: r.start_date.to_string(),
|
||||
end_date: r.end_date.to_string(),
|
||||
created_at: r.created_at.to_string(),
|
||||
completed_at: r.completed_at.map(|t| t.to_string()),
|
||||
error_message: r.error_message.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/wrapups/generate",
|
||||
request_body = GenerateWrapUpRequest,
|
||||
responses(
|
||||
(status = 200, body = WrapUpGeneratedResponse),
|
||||
(status = 400, description = "Invalid date format"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden — admin only"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn post_generate(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminApiUser,
|
||||
Json(req): Json<GenerateWrapUpRequest>,
|
||||
) -> Result<Json<WrapUpGeneratedResponse>, ApiError> {
|
||||
let start = NaiveDate::parse_from_str(&req.start_date, "%Y-%m-%d")
|
||||
.map_err(|_| DomainError::ValidationError("invalid start_date".into()))?;
|
||||
let end = NaiveDate::parse_from_str(&req.end_date, "%Y-%m-%d")
|
||||
.map_err(|_| DomainError::ValidationError("invalid end_date".into()))?;
|
||||
let user_id = req.user_id;
|
||||
let cmd = RequestWrapUpCommand {
|
||||
user_id,
|
||||
start_date: start,
|
||||
end_date: end,
|
||||
};
|
||||
let id = generate::execute(&state.app_ctx.deps.wrapup.generate, cmd).await?;
|
||||
Ok(Json(WrapUpGeneratedResponse {
|
||||
id: id.value().to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/wrapups",
|
||||
responses(
|
||||
(status = 200, body = WrapUpListResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_list(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
) -> Result<Json<WrapUpListResponse>, ApiError> {
|
||||
let records = list_wrapups::execute(
|
||||
&state.app_ctx.deps.wrapup.list_wrapups,
|
||||
ListWrapUpsQuery {
|
||||
user_id: Some(user.0.value()),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(WrapUpListResponse {
|
||||
items: records.iter().map(record_to_dto).collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/wrapups/{id}",
|
||||
params(("id" = Uuid, Path, description = "Wrap-up ID")),
|
||||
responses(
|
||||
(status = 200, body = WrapUpStatusResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_status(
|
||||
State(state): State<AppState>,
|
||||
_user: AuthenticatedUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<WrapUpStatusResponse>, ApiError> {
|
||||
let record = get_wrapup::execute(
|
||||
&state.app_ctx.deps.wrapup.get_wrapup,
|
||||
WrapUpId::from_uuid(id),
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("wrap-up not found".into()))?;
|
||||
Ok(Json(record_to_dto(&record)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/wrapups/{id}/report",
|
||||
params(("id" = Uuid, Path, description = "Wrap-up ID")),
|
||||
responses(
|
||||
(status = 200, description = "Report JSON", content_type = "application/json"),
|
||||
(status = 202, description = "Still generating"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn get_report(
|
||||
State(state): State<AppState>,
|
||||
_user: AuthenticatedUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> impl IntoResponse {
|
||||
match get_wrapup::execute(
|
||||
&state.app_ctx.deps.wrapup.get_wrapup,
|
||||
WrapUpId::from_uuid(id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(record)) if record.status == WrapUpStatus::Ready => match record.report {
|
||||
Some(ref report) => {
|
||||
let json = serde_json::to_string(report).unwrap_or_default();
|
||||
(StatusCode::OK, [("content-type", "application/json")], json).into_response()
|
||||
}
|
||||
None => StatusCode::NOT_FOUND.into_response(),
|
||||
},
|
||||
Ok(Some(_)) => StatusCode::ACCEPTED.into_response(),
|
||||
Ok(None) => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(e) => crate::errors::domain_error_response(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/v1/wrapups/{id}",
|
||||
params(("id" = Uuid, Path, description = "Wrap-up ID")),
|
||||
responses(
|
||||
(status = 204, description = "Deleted"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Forbidden — admin only"),
|
||||
(status = 404, description = "Not found"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete_wrapup_handler(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminApiUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
delete_wrapup::execute(
|
||||
&state.app_ctx.deps.wrapup.delete_wrapup,
|
||||
WrapUpId::from_uuid(id),
|
||||
)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
Reference in New Issue
Block a user