importer feature
This commit is contained in:
@@ -326,6 +326,22 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::ImportSessionRepository for Panic {
|
||||
async fn create(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { panic!() }
|
||||
async fn get(&self, _: &domain::value_objects::ImportSessionId, _: &UserId) -> Result<Option<domain::models::ImportSession>, DomainError> { panic!() }
|
||||
async fn update(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { panic!() }
|
||||
async fn delete(&self, _: &domain::value_objects::ImportSessionId) -> Result<(), DomainError> { panic!() }
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError> { panic!() }
|
||||
async fn delete_expired_for_user(&self, _: &UserId) -> Result<(), DomainError> { panic!() }
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::ImportProfileRepository for Panic {
|
||||
async fn save(&self, _: &domain::models::ImportProfile) -> Result<(), DomainError> { panic!() }
|
||||
async fn list_for_user(&self, _: &UserId) -> Result<Vec<domain::models::ImportProfile>, DomainError> { panic!() }
|
||||
async fn get(&self, _: &domain::value_objects::ImportProfileId, _: &UserId) -> Result<Option<domain::models::ImportProfile>, DomainError> { panic!() }
|
||||
async fn delete(&self, _: &domain::value_objects::ImportProfileId) -> Result<(), DomainError> { panic!() }
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::DiaryExporter for Panic {
|
||||
async fn serialize_entries(
|
||||
&self,
|
||||
@@ -392,6 +408,9 @@ mod tests {
|
||||
) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
fn render_import_upload_page(&self, _: application::ports::ImportUploadPageData) -> Result<String, String> { panic!() }
|
||||
fn render_import_mapping_page(&self, _: application::ports::ImportMappingPageData) -> Result<String, String> { panic!() }
|
||||
fn render_import_preview_page(&self, _: application::ports::ImportPreviewPageData) -> Result<String, String> { panic!() }
|
||||
}
|
||||
impl crate::ports::RssFeedRenderer for Panic {
|
||||
fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result<String, String> {
|
||||
@@ -427,6 +446,8 @@ mod tests {
|
||||
event_publisher: Arc::clone(&repo) as _,
|
||||
password_hasher: Arc::clone(&repo) as _,
|
||||
user_repository: Arc::clone(&repo) as _,
|
||||
import_session_repository: Arc::clone(&repo) as _,
|
||||
import_profile_repository: Arc::clone(&repo) as _,
|
||||
auth_service,
|
||||
config: AppConfig {
|
||||
allow_registration: false,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
721
crates/presentation/src/handlers/api.rs
Normal file
721
crates/presentation/src/handlers/api.rs
Normal file
@@ -0,0 +1,721 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use application::{
|
||||
commands::{
|
||||
DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand, SyncPosterCommand,
|
||||
},
|
||||
queries::{
|
||||
GetActivityFeedQuery, GetReviewHistoryQuery, GetUserProfileQuery, GetUsersQuery,
|
||||
},
|
||||
use_cases::{
|
||||
delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc,
|
||||
get_diary, get_review_history, get_user_profile as get_user_profile_uc, get_users,
|
||||
log_review, login as login_uc, register as register_uc, sync_poster,
|
||||
},
|
||||
};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{DiaryEntry, ExportFormat, Movie, Review},
|
||||
services::review_history::Trend,
|
||||
value_objects::{MovieId, UserId},
|
||||
};
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
use crate::dtos::{ActorListResponse, ActorUrlRequest, FollowRequest, RemoteActorDto};
|
||||
use crate::{
|
||||
dtos::{
|
||||
ActivityFeedQueryParams, ActivityFeedResponse, DiaryEntryDto, DiaryQueryParams,
|
||||
DiaryResponse, DirectorStatDto, ExportQueryParams, FeedEntryDto, LogReviewData,
|
||||
LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto, MonthlyRatingDto,
|
||||
MovieDto, RegisterRequest, ReviewDto, ReviewHistoryResponse, UserProfileQueryParams,
|
||||
UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse,
|
||||
},
|
||||
errors::ApiError,
|
||||
extractors::AuthenticatedUser,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[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, params.into()).await?;
|
||||
|
||||
Ok(Json(DiaryResponse {
|
||||
items: page.items.iter().map(entry_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, GetReviewHistoryQuery { movie_id }).await?;
|
||||
|
||||
Ok(Json(ReviewHistoryResponse {
|
||||
movie: movie_to_dto(history.movie()),
|
||||
viewings: history.viewings().iter().map(review_to_dto).collect(),
|
||||
trend: match trend {
|
||||
Trend::Improved => "improved",
|
||||
Trend::Declined => "declined",
|
||||
Trend::Neutral => "neutral",
|
||||
}
|
||||
.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[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, data.into_command(user.0.value())).await?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
#[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> {
|
||||
let movie = state
|
||||
.app_ctx
|
||||
.movie_repository
|
||||
.get_movie_by_id(&MovieId::from_uuid(movie_id))
|
||||
.await?
|
||||
.ok_or_else(|| ApiError(DomainError::NotFound(format!("Movie {movie_id}"))))?;
|
||||
|
||||
let external_id = movie
|
||||
.external_metadata_id()
|
||||
.ok_or_else(|| {
|
||||
ApiError(DomainError::ValidationError(
|
||||
"Movie has no external metadata ID, cannot sync poster".into(),
|
||||
))
|
||||
})?
|
||||
.value()
|
||||
.to_string();
|
||||
|
||||
sync_poster::execute(
|
||||
&state.app_ctx,
|
||||
SyncPosterCommand {
|
||||
movie_id,
|
||||
external_metadata_id: external_id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[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,
|
||||
LoginCommand {
|
||||
email: req.email,
|
||||
password: req.password,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(LoginResponse {
|
||||
token: result.token,
|
||||
user_id: result.user_id,
|
||||
email: result.email,
|
||||
expires_at: result.expires_at.to_rfc3339(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[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,
|
||||
RegisterCommand {
|
||||
email: req.email,
|
||||
username: req.username,
|
||||
password: req.password,
|
||||
role: domain::models::UserRole::Standard,
|
||||
},
|
||||
)
|
||||
.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>,
|
||||
) -> impl IntoResponse {
|
||||
let cmd = DeleteReviewCommand {
|
||||
review_id,
|
||||
requesting_user_id: user_id.value(),
|
||||
};
|
||||
match delete_review::execute(&state.app_ctx, cmd).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(DomainError::Unauthorized(_)) => StatusCode::FORBIDDEN.into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("delete_review error: {:?}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn movie_to_dto(movie: &Movie) -> MovieDto {
|
||||
MovieDto {
|
||||
id: movie.id().value(),
|
||||
title: movie.title().value().to_string(),
|
||||
release_year: movie.release_year().value(),
|
||||
director: movie.director().map(|d| d.to_string()),
|
||||
poster_path: movie.poster_path().map(|p| p.value().to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn review_to_dto(review: &Review) -> ReviewDto {
|
||||
ReviewDto {
|
||||
id: review.id().value(),
|
||||
rating: review.rating().value(),
|
||||
comment: review.comment().map(|c| c.value().to_string()),
|
||||
watched_at: review.watched_at().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_to_dto(entry: &DiaryEntry) -> DiaryEntryDto {
|
||||
DiaryEntryDto {
|
||||
movie: movie_to_dto(entry.movie()),
|
||||
review: review_to_dto(entry.review()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
fn ap_err(e: anyhow::Error) -> impl IntoResponse {
|
||||
tracing::error!("ActivityPub error: {:?}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
#[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,
|
||||
) -> impl IntoResponse {
|
||||
match state.ap_service.get_following(user.0.value()).await {
|
||||
Ok(actors) => Json(ActorListResponse {
|
||||
actors: actors
|
||||
.into_iter()
|
||||
.map(|a| RemoteActorDto {
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
url: a.url,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.into_response(),
|
||||
Err(e) => ap_err(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
#[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,
|
||||
) -> impl IntoResponse {
|
||||
match state
|
||||
.ap_service
|
||||
.get_accepted_followers(user.0.value())
|
||||
.await
|
||||
{
|
||||
Ok(actors) => Json(ActorListResponse {
|
||||
actors: actors
|
||||
.into_iter()
|
||||
.map(|a| RemoteActorDto {
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
url: a.url,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.into_response(),
|
||||
Err(e) => ap_err(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
#[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>,
|
||||
) -> impl IntoResponse {
|
||||
match state.ap_service.follow(user.0.value(), &body.handle).await {
|
||||
Ok(()) => StatusCode::OK.into_response(),
|
||||
Err(e) => ap_err(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
#[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>,
|
||||
) -> impl IntoResponse {
|
||||
match state
|
||||
.ap_service
|
||||
.unfollow(user.0.value(), &body.actor_url)
|
||||
.await
|
||||
{
|
||||
Ok(()) => StatusCode::OK.into_response(),
|
||||
Err(e) => ap_err(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
#[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>,
|
||||
) -> impl IntoResponse {
|
||||
match state
|
||||
.ap_service
|
||||
.accept_follower(user.0.value(), &body.actor_url)
|
||||
.await
|
||||
{
|
||||
Ok(()) => StatusCode::OK.into_response(),
|
||||
Err(e) => ap_err(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
#[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>,
|
||||
) -> impl IntoResponse {
|
||||
match state
|
||||
.ap_service
|
||||
.reject_follower(user.0.value(), &body.actor_url)
|
||||
.await
|
||||
{
|
||||
Ok(()) => StatusCode::OK.into_response(),
|
||||
Err(e) => ap_err(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
#[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>,
|
||||
) -> impl IntoResponse {
|
||||
match state
|
||||
.ap_service
|
||||
.remove_follower(user.0.value(), &body.actor_url)
|
||||
.await
|
||||
{
|
||||
Ok(()) => StatusCode::OK.into_response(),
|
||||
Err(e) => ap_err(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
#[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,
|
||||
) -> impl IntoResponse {
|
||||
match state.ap_service.get_pending_followers(user.0.value()).await {
|
||||
Ok(actors) => Json(ActorListResponse {
|
||||
actors: actors
|
||||
.into_iter()
|
||||
.map(|a| RemoteActorDto {
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
url: a.url,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.into_response(),
|
||||
Err(e) => ap_err(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
GetActivityFeedQuery {
|
||||
limit: params.limit.unwrap_or(20),
|
||||
offset: params.offset.unwrap_or(0),
|
||||
sort_by: domain::ports::FeedSortBy::Date,
|
||||
search: None,
|
||||
following: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ActivityFeedResponse {
|
||||
items: page
|
||||
.items
|
||||
.iter()
|
||||
.map(|e| FeedEntryDto {
|
||||
movie: movie_to_dto(e.movie()),
|
||||
review: review_to_dto(e.review()),
|
||||
user_email: e.user_email().to_string(),
|
||||
user_display_name: e.user_display_name().to_string(),
|
||||
})
|
||||
.collect(),
|
||||
total_count: page.total_count,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
}))
|
||||
}
|
||||
|
||||
#[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 users = get_users::execute(&state.app_ctx, GetUsersQuery).await?;
|
||||
Ok(Json(UsersResponse {
|
||||
users: users
|
||||
.iter()
|
||||
.map(|u| UserSummaryDto {
|
||||
id: u.user_id.value(),
|
||||
email: u.email().to_string(),
|
||||
total_movies: u.total_movies,
|
||||
avg_rating: u.avg_rating,
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[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>,
|
||||
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::queries::ProfileView::from_str(view_str) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
|
||||
let user = match state
|
||||
.app_ctx
|
||||
.user_repository
|
||||
.find_by_id(&UserId::from_uuid(user_id))
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("user lookup: {:?}", e);
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let profile = match get_user_profile_uc::execute(
|
||||
&state.app_ctx,
|
||||
GetUserProfileQuery {
|
||||
user_id,
|
||||
view: profile_view,
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
sort_by: domain::ports::FeedSortBy::Date,
|
||||
search: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::error!("profile: {:?}", e);
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
let following_count = state.ap_service.count_following(user_id).await.unwrap_or(0);
|
||||
#[cfg(not(feature = "federation"))]
|
||||
let following_count = 0usize;
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
let followers_count = state
|
||||
.ap_service
|
||||
.count_accepted_followers(user_id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
#[cfg(not(feature = "federation"))]
|
||||
let followers_count = 0usize;
|
||||
|
||||
let entries = profile.entries.map(|p| DiaryResponse {
|
||||
items: p.items.iter().map(entry_to_dto).collect(),
|
||||
total_count: p.total_count,
|
||||
limit: p.limit,
|
||||
offset: p.offset,
|
||||
});
|
||||
|
||||
let history = profile.history.map(|months| {
|
||||
months
|
||||
.into_iter()
|
||||
.map(|m| MonthActivityDto {
|
||||
year_month: m.year_month,
|
||||
month_label: m.month_label,
|
||||
count: m.count,
|
||||
entries: m.entries.iter().map(entry_to_dto).collect(),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
let trends = profile.trends.map(|t| 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,
|
||||
});
|
||||
|
||||
Json(UserProfileResponse {
|
||||
user_id,
|
||||
username: user.username().value().to_string(),
|
||||
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,
|
||||
followers_count,
|
||||
entries,
|
||||
history,
|
||||
trends,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[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 {
|
||||
let format = match params.format.as_str() {
|
||||
"csv" => ExportFormat::Csv,
|
||||
"json" => ExportFormat::Json,
|
||||
_ => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
let (content_type, filename) = match &format {
|
||||
ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"),
|
||||
ExportFormat::Json => ("application/json", "diary.json"),
|
||||
};
|
||||
let cmd = ExportCommand {
|
||||
user_id: user.0.value(),
|
||||
format,
|
||||
};
|
||||
match export_diary_uc::execute(&state.app_ctx, cmd).await {
|
||||
Ok(bytes) => (
|
||||
StatusCode::OK,
|
||||
[
|
||||
(axum::http::header::CONTENT_TYPE, content_type.to_string()),
|
||||
(
|
||||
axum::http::header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{}\"", filename),
|
||||
),
|
||||
],
|
||||
bytes,
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("export error: {:?}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
918
crates/presentation/src/handlers/html.rs
Normal file
918
crates/presentation/src/handlers/html.rs
Normal file
@@ -0,0 +1,918 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use axum::{
|
||||
Form,
|
||||
extract::{Extension, Path, Query, State},
|
||||
http::{HeaderValue, StatusCode, header::SET_COOKIE},
|
||||
response::{Html, IntoResponse, Redirect},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
use application::ports::{FollowersPageData, FollowingPageData};
|
||||
use application::{
|
||||
commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand},
|
||||
ports::{
|
||||
HtmlPageContext, LoginPageData, NewReviewPageData, RegisterPageData, RemoteActorView,
|
||||
},
|
||||
use_cases::{
|
||||
delete_review, export_diary as export_diary_uc, log_review, login as login_uc,
|
||||
register as register_uc,
|
||||
},
|
||||
};
|
||||
use domain::models::ExportFormat;
|
||||
use domain::{errors::DomainError, value_objects::UserId};
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
use crate::dtos::{FollowForm, FollowerActionForm, UnfollowForm};
|
||||
use crate::{
|
||||
csrf::CsrfToken,
|
||||
dtos::{
|
||||
ErrorQuery, FeedQueryParams, LogReviewData, LogReviewForm, LoginForm, RegisterForm,
|
||||
},
|
||||
extractors::{OptionalCookieUser, RequiredCookieUser},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub(crate) async fn build_page_context(
|
||||
state: &AppState,
|
||||
user_id: Option<UserId>,
|
||||
csrf_token: String,
|
||||
) -> HtmlPageContext {
|
||||
let uuid = user_id.as_ref().map(|u| u.value());
|
||||
let user_email = if let Some(ref id) = user_id {
|
||||
state
|
||||
.app_ctx
|
||||
.user_repository
|
||||
.find_by_id(id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|u| u.email().value().to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
HtmlPageContext {
|
||||
user_email,
|
||||
user_id: uuid,
|
||||
register_enabled: state.app_ctx.config.allow_registration,
|
||||
rss_url: "/feed.rss".to_string(),
|
||||
page_title: "Movies Diary".to_string(),
|
||||
canonical_url: state.app_ctx.config.base_url.clone(),
|
||||
csrf_token,
|
||||
page_rss_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_error(msg: &str) -> String {
|
||||
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
|
||||
utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string()
|
||||
}
|
||||
|
||||
fn secure_flag() -> &'static str {
|
||||
if std::env::var("SECURE_COOKIES").as_deref() == Ok("true") {
|
||||
"; Secure"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
fn set_cookie_header(token: &str, max_age: i64) -> (axum::http::HeaderName, HeaderValue) {
|
||||
let val = format!(
|
||||
"token={}; HttpOnly; Path=/; SameSite=Strict; Max-Age={}{}",
|
||||
token,
|
||||
max_age,
|
||||
secure_flag()
|
||||
);
|
||||
(
|
||||
SET_COOKIE,
|
||||
HeaderValue::from_str(&val).expect("valid cookie"),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn get_login_page(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ErrorQuery>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let ctx = HtmlPageContext {
|
||||
user_email: None,
|
||||
user_id: None,
|
||||
register_enabled: state.app_ctx.config.allow_registration,
|
||||
rss_url: "/feed.rss".to_string(),
|
||||
page_title: "Login — Movies Diary".to_string(),
|
||||
canonical_url: format!("{}/login", state.app_ctx.config.base_url),
|
||||
csrf_token: csrf.0,
|
||||
page_rss_url: None,
|
||||
};
|
||||
let html = state
|
||||
.html_renderer
|
||||
.render_login_page(LoginPageData {
|
||||
ctx,
|
||||
error: params.error.as_deref(),
|
||||
})
|
||||
.expect("login template failed");
|
||||
Html(html)
|
||||
}
|
||||
|
||||
pub async fn post_login(
|
||||
State(state): State<AppState>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<LoginForm>,
|
||||
) -> impl IntoResponse {
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
match login_uc::execute(
|
||||
&state.app_ctx,
|
||||
LoginCommand {
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
let max_age = (result.expires_at - Utc::now()).num_seconds().max(0);
|
||||
let cookie = set_cookie_header(&result.token, max_age);
|
||||
([cookie], Redirect::to("/")).into_response()
|
||||
}
|
||||
Err(_) => Redirect::to("/login?error=Invalid+credentials").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_logout() -> impl IntoResponse {
|
||||
let val = format!(
|
||||
"token=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0{}",
|
||||
secure_flag()
|
||||
);
|
||||
let cookie = (
|
||||
SET_COOKIE,
|
||||
HeaderValue::from_str(&val).expect("valid cookie"),
|
||||
);
|
||||
([cookie], Redirect::to("/")).into_response()
|
||||
}
|
||||
|
||||
pub async fn get_register_page(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ErrorQuery>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
if !state.app_ctx.config.allow_registration {
|
||||
return Redirect::to("/").into_response();
|
||||
}
|
||||
let ctx = HtmlPageContext {
|
||||
user_email: None,
|
||||
user_id: None,
|
||||
register_enabled: true,
|
||||
rss_url: "/feed.rss".to_string(),
|
||||
page_title: "Register — Movies Diary".to_string(),
|
||||
canonical_url: format!("{}/register", state.app_ctx.config.base_url),
|
||||
csrf_token: csrf.0,
|
||||
page_rss_url: None,
|
||||
};
|
||||
let html = state
|
||||
.html_renderer
|
||||
.render_register_page(RegisterPageData {
|
||||
ctx,
|
||||
error: params.error.as_deref(),
|
||||
})
|
||||
.expect("register template failed");
|
||||
Html(html).into_response()
|
||||
}
|
||||
|
||||
pub async fn post_register(
|
||||
State(state): State<AppState>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<RegisterForm>,
|
||||
) -> impl IntoResponse {
|
||||
if !state.app_ctx.config.allow_registration {
|
||||
return Redirect::to("/").into_response();
|
||||
}
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let email = form.email.clone();
|
||||
let password = form.password.clone();
|
||||
match register_uc::execute(
|
||||
&state.app_ctx,
|
||||
RegisterCommand {
|
||||
email: form.email,
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
role: domain::models::UserRole::Standard,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
match login_uc::execute(&state.app_ctx, LoginCommand { email, password }).await {
|
||||
Ok(result) => {
|
||||
let max_age = (result.expires_at - Utc::now()).num_seconds().max(0);
|
||||
let cookie = set_cookie_header(&result.token, max_age);
|
||||
([cookie], Redirect::to("/")).into_response()
|
||||
}
|
||||
Err(_) => Redirect::to("/login").into_response(),
|
||||
}
|
||||
}
|
||||
Err(_) => Redirect::to("/register?error=Registration+failed.+Please+try+again.")
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_new_review_page(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<ErrorQuery>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let mut ctx = build_page_context(&state, Some(user_id), csrf.0).await;
|
||||
ctx.page_title = "Log a Review — Movies Diary".to_string();
|
||||
ctx.canonical_url = format!("{}/reviews/new", state.app_ctx.config.base_url);
|
||||
let html = state
|
||||
.html_renderer
|
||||
.render_new_review_page(NewReviewPageData {
|
||||
ctx,
|
||||
error: params.error.as_deref(),
|
||||
})
|
||||
.expect("new_review template failed");
|
||||
Html(html)
|
||||
}
|
||||
|
||||
pub async fn post_review(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<LogReviewForm>,
|
||||
) -> impl IntoResponse {
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let data = match LogReviewData::try_from(form) {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return Redirect::to("/reviews/new?error=Invalid+date+format").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
match log_review::execute(&state.app_ctx, data.into_command(user_id.value())).await {
|
||||
Ok(_) => Redirect::to("/").into_response(),
|
||||
Err(e) => {
|
||||
let msg = encode_error(&e.to_string());
|
||||
Redirect::to(&format!("/reviews/new?error={}", msg)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_delete_review(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Path(review_id): Path<Uuid>,
|
||||
Form(form): Form<crate::dtos::DeleteRedirectForm>,
|
||||
) -> impl IntoResponse {
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let cmd = DeleteReviewCommand {
|
||||
review_id,
|
||||
requesting_user_id: user_id.value(),
|
||||
};
|
||||
match delete_review::execute(&state.app_ctx, cmd).await {
|
||||
Ok(()) => {
|
||||
let redirect_url = form
|
||||
.redirect_after
|
||||
.filter(|url| {
|
||||
(url.starts_with('/') && !url.starts_with("//")) || url.starts_with('?')
|
||||
})
|
||||
.unwrap_or_else(|| "/".to_string());
|
||||
Redirect::to(&redirect_url).into_response()
|
||||
}
|
||||
Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(DomainError::Unauthorized(_)) => StatusCode::FORBIDDEN.into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("delete_review html error: {:?}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_export(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Query(params): Query<crate::dtos::ExportQueryParams>,
|
||||
) -> impl IntoResponse {
|
||||
let format = match params.format.as_str() {
|
||||
"csv" => ExportFormat::Csv,
|
||||
"json" => ExportFormat::Json,
|
||||
_ => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
let (content_type, filename) = match &format {
|
||||
ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"),
|
||||
ExportFormat::Json => ("application/json", "diary.json"),
|
||||
};
|
||||
let cmd = ExportCommand {
|
||||
user_id: user_id.value(),
|
||||
format,
|
||||
};
|
||||
match export_diary_uc::execute(&state.app_ctx, cmd).await {
|
||||
Ok(bytes) => (
|
||||
StatusCode::OK,
|
||||
[
|
||||
(axum::http::header::CONTENT_TYPE, content_type.to_string()),
|
||||
(
|
||||
axum::http::header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{}\"", filename),
|
||||
),
|
||||
],
|
||||
bytes,
|
||||
)
|
||||
.into_response(),
|
||||
Err(DomainError::Unauthorized(_)) => StatusCode::FORBIDDEN.into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("export error: {:?}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_activity_feed(
|
||||
OptionalCookieUser(user_id): OptionalCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<FeedQueryParams>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let ctx = build_page_context(&state, user_id.clone(), csrf.0).await;
|
||||
let limit = params.limit.unwrap_or(20);
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
let filter_str = if params.filter == "following" && user_id.is_some() {
|
||||
"following"
|
||||
} else {
|
||||
"all"
|
||||
};
|
||||
#[cfg(not(feature = "federation"))]
|
||||
let filter_str = "all";
|
||||
|
||||
let sort_by_str = match params.sort_by.as_str() {
|
||||
"date_asc" => "date_asc",
|
||||
"rating" => "rating",
|
||||
"rating_asc" => "rating_asc",
|
||||
_ => "date",
|
||||
};
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
let following = if filter_str == "following" {
|
||||
if let Some(uid) = user_id {
|
||||
let urls = state
|
||||
.social_query
|
||||
.get_accepted_following_urls(uid.value())
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let base_url = &state.app_ctx.config.base_url;
|
||||
let mut local_ids = vec![uid.value()];
|
||||
let mut remote_urls = Vec::new();
|
||||
for url in urls {
|
||||
if let Some(suffix) = url.strip_prefix(&format!("{}/users/", base_url)) {
|
||||
if let Ok(parsed_id) = uuid::Uuid::parse_str(suffix) {
|
||||
local_ids.push(parsed_id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
remote_urls.push(url);
|
||||
}
|
||||
Some(domain::ports::FollowingFilter {
|
||||
local_user_ids: local_ids,
|
||||
remote_actor_urls: remote_urls,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "federation"))]
|
||||
let following: Option<domain::ports::FollowingFilter> = None;
|
||||
|
||||
let search_opt = if params.search.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(params.search.clone())
|
||||
};
|
||||
|
||||
let query = application::queries::GetActivityFeedQuery {
|
||||
limit,
|
||||
offset,
|
||||
sort_by: domain::ports::FeedSortBy::from_str(sort_by_str),
|
||||
search: search_opt,
|
||||
following,
|
||||
};
|
||||
|
||||
match application::use_cases::get_activity_feed::execute(&state.app_ctx, query).await {
|
||||
Ok(entries) => {
|
||||
let entry_limit = entries.limit;
|
||||
let entry_offset = entries.offset;
|
||||
let has_more =
|
||||
(entry_offset as u64).saturating_add(entry_limit as u64) < entries.total_count;
|
||||
let data = application::ports::ActivityFeedPageData {
|
||||
ctx,
|
||||
current_offset: entry_offset,
|
||||
has_more,
|
||||
limit: entry_limit,
|
||||
entries,
|
||||
filter: filter_str.to_string(),
|
||||
sort_by: sort_by_str.to_string(),
|
||||
search: params.search,
|
||||
};
|
||||
match state.html_renderer.render_activity_feed_page(data) {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_users_list(
|
||||
OptionalCookieUser(user_id): OptionalCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let mut ctx = build_page_context(&state, user_id, csrf.0).await;
|
||||
ctx.page_title = "Members — Movies Diary".to_string();
|
||||
ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url);
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
let (users_result, actors_result) = tokio::join!(
|
||||
application::use_cases::get_users::execute(
|
||||
&state.app_ctx,
|
||||
application::queries::GetUsersQuery,
|
||||
),
|
||||
state.social_query.list_all_followed_remote_actors()
|
||||
);
|
||||
#[cfg(not(feature = "federation"))]
|
||||
let (users_result, actors_result) = (
|
||||
application::use_cases::get_users::execute(
|
||||
&state.app_ctx,
|
||||
application::queries::GetUsersQuery,
|
||||
)
|
||||
.await,
|
||||
Ok::<Vec<domain::ports::RemoteActorInfo>, domain::errors::DomainError>(vec![]),
|
||||
);
|
||||
|
||||
match (users_result, actors_result) {
|
||||
(Ok(users), Ok(remote_actors)) => {
|
||||
let actor_views = remote_actors
|
||||
.into_iter()
|
||||
.map(|a| application::ports::RemoteActorView {
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
url: a.url,
|
||||
})
|
||||
.collect();
|
||||
let data = application::ports::UsersPageData {
|
||||
ctx,
|
||||
users,
|
||||
remote_actors: actor_views,
|
||||
};
|
||||
match state.html_renderer.render_users_page(data) {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
}
|
||||
(Err(e), _) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
(_, Err(e)) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_user_profile(
|
||||
OptionalCookieUser(user_id): OptionalCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(profile_user_uuid): Path<Uuid>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Query(params): Query<crate::dtos::ProfileQueryParams>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
// Content negotiation: AP clients request application/activity+json
|
||||
#[cfg(feature = "federation")]
|
||||
{
|
||||
let accept = headers
|
||||
.get(axum::http::header::ACCEPT)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
if accept.contains("application/activity+json")
|
||||
|| accept.contains("application/ld+json")
|
||||
{
|
||||
return match state
|
||||
.ap_service
|
||||
.actor_json(&profile_user_uuid.to_string())
|
||||
.await
|
||||
{
|
||||
Ok(json) => (
|
||||
[(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
"application/activity+json",
|
||||
)],
|
||||
json,
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let mut ctx = build_page_context(&state, user_id.clone(), csrf.0).await;
|
||||
let view_str = params.view.as_deref().unwrap_or("recent");
|
||||
let profile_view = match application::queries::ProfileView::from_str(view_str) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return (
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
"invalid view parameter",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let profile_user = match state
|
||||
.app_ctx
|
||||
.user_repository
|
||||
.find_by_id(&domain::value_objects::UserId::from_uuid(profile_user_uuid))
|
||||
.await
|
||||
{
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "User not found").into_response(),
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
};
|
||||
|
||||
let display_name = profile_user.username().value();
|
||||
ctx.page_title = format!("{}'s Diary — Movies Diary", display_name);
|
||||
ctx.canonical_url = format!(
|
||||
"{}/users/{}",
|
||||
state.app_ctx.config.base_url, profile_user_uuid
|
||||
);
|
||||
|
||||
let sort_by_str = match params.sort_by.as_str() {
|
||||
"date_asc" => "date_asc",
|
||||
"rating" => "rating",
|
||||
"rating_asc" => "rating_asc",
|
||||
_ => "date",
|
||||
};
|
||||
|
||||
let is_own_profile = user_id
|
||||
.as_ref()
|
||||
.map(|u| u.value() == profile_user_uuid)
|
||||
.unwrap_or(false);
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
let following_count = if is_own_profile {
|
||||
if let Some(ref uid) = user_id {
|
||||
state
|
||||
.ap_service
|
||||
.count_following(uid.value())
|
||||
.await
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
#[cfg(not(feature = "federation"))]
|
||||
let following_count = 0usize;
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
let followers_count = if is_own_profile {
|
||||
state
|
||||
.ap_service
|
||||
.count_accepted_followers(profile_user_uuid)
|
||||
.await
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
#[cfg(not(feature = "federation"))]
|
||||
let followers_count = 0usize;
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
let pending_followers: Vec<application::ports::RemoteActorView> = if is_own_profile {
|
||||
state
|
||||
.ap_service
|
||||
.get_pending_followers(profile_user_uuid)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|a| application::ports::RemoteActorView {
|
||||
handle: a.handle,
|
||||
url: a.url,
|
||||
display_name: a.display_name,
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
#[cfg(not(feature = "federation"))]
|
||||
let pending_followers: Vec<application::ports::RemoteActorView> = vec![];
|
||||
|
||||
let query = application::queries::GetUserProfileQuery {
|
||||
user_id: profile_user_uuid,
|
||||
view: profile_view,
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
sort_by: domain::ports::FeedSortBy::from_str(sort_by_str),
|
||||
search: if params.search.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(params.search.clone())
|
||||
},
|
||||
};
|
||||
|
||||
match application::use_cases::get_user_profile::execute(&state.app_ctx, query).await {
|
||||
Ok(profile) => {
|
||||
let (offset, has_more, limit) = profile
|
||||
.entries
|
||||
.as_ref()
|
||||
.map(|e| {
|
||||
let has_more =
|
||||
(e.offset as u64).saturating_add(e.limit as u64) < e.total_count;
|
||||
(e.offset, has_more, e.limit)
|
||||
})
|
||||
.unwrap_or((0, false, super::DEFAULT_PAGE_LIMIT));
|
||||
if !is_own_profile {
|
||||
ctx.page_rss_url = Some(format!("/users/{}/feed.rss", profile_user_uuid));
|
||||
}
|
||||
let data = application::ports::ProfilePageData {
|
||||
ctx,
|
||||
profile_user_id: profile_user_uuid,
|
||||
profile_user_email: profile_user.email().value().to_string(),
|
||||
stats: profile.stats,
|
||||
view: profile_view.as_str().to_string(),
|
||||
entries: profile.entries,
|
||||
current_offset: offset,
|
||||
has_more,
|
||||
limit,
|
||||
history: profile.history,
|
||||
trends: profile.trends,
|
||||
is_own_profile,
|
||||
error: params.error,
|
||||
following_count,
|
||||
followers_count,
|
||||
pending_followers,
|
||||
sort_by: sort_by_str.to_string(),
|
||||
search: params.search.clone(),
|
||||
};
|
||||
match state.html_renderer.render_profile_page(data) {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
pub async fn follow_remote_user(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(profile_user_uuid): Path<Uuid>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<FollowForm>,
|
||||
) -> impl IntoResponse {
|
||||
if user_id.value() != profile_user_uuid {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
match state.ap_service.follow(user_id.value(), &form.handle).await {
|
||||
Ok(()) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("follow error: {:?}", e);
|
||||
let msg = encode_error(&e.to_string());
|
||||
Redirect::to(&format!("/users/{}?error={}", profile_user_uuid, msg)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
pub async fn unfollow_remote_user(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(profile_user_uuid): Path<Uuid>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<UnfollowForm>,
|
||||
) -> impl IntoResponse {
|
||||
if user_id.value() != profile_user_uuid {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
match state
|
||||
.ap_service
|
||||
.unfollow(user_id.value(), &form.actor_url)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Redirect::to(&format!("/users/{}/following-list", profile_user_uuid))
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
let msg = encode_error(&e.to_string());
|
||||
Redirect::to(&format!(
|
||||
"/users/{}/following-list?error={}",
|
||||
profile_user_uuid, msg
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
pub async fn accept_follower(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(profile_user_uuid): Path<Uuid>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<FollowerActionForm>,
|
||||
) -> impl IntoResponse {
|
||||
if user_id.value() != profile_user_uuid {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
match state
|
||||
.ap_service
|
||||
.accept_follower(user_id.value(), &form.actor_url)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(),
|
||||
Err(e) => {
|
||||
let msg = encode_error(&e.to_string());
|
||||
Redirect::to(&format!("/users/{}?error={}", profile_user_uuid, msg)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
pub async fn reject_follower(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(profile_user_uuid): Path<Uuid>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<FollowerActionForm>,
|
||||
) -> impl IntoResponse {
|
||||
if user_id.value() != profile_user_uuid {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
match state
|
||||
.ap_service
|
||||
.reject_follower(user_id.value(), &form.actor_url)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Redirect::to(&format!("/users/{}", profile_user_uuid)).into_response(),
|
||||
Err(e) => {
|
||||
let msg = encode_error(&e.to_string());
|
||||
Redirect::to(&format!("/users/{}?error={}", profile_user_uuid, msg)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
pub async fn get_following_page(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(profile_user_uuid): Path<Uuid>,
|
||||
Query(params): Query<crate::dtos::ErrorQuery>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
if user_id.value() != profile_user_uuid {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await;
|
||||
ctx.page_title = "Following — Movies Diary".to_string();
|
||||
ctx.canonical_url = format!(
|
||||
"{}/users/{}/following-list",
|
||||
state.app_ctx.config.base_url, profile_user_uuid
|
||||
);
|
||||
match state.ap_service.get_following(user_id.value()).await {
|
||||
Ok(following) => {
|
||||
let actors = following
|
||||
.into_iter()
|
||||
.map(|a| RemoteActorView {
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
url: a.url,
|
||||
})
|
||||
.collect();
|
||||
let data = FollowingPageData {
|
||||
ctx,
|
||||
user_id: profile_user_uuid,
|
||||
actors,
|
||||
error: params.error,
|
||||
};
|
||||
match state.html_renderer.render_following_page(data) {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("get_following error: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to load following list",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
pub async fn get_followers_page(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(profile_user_uuid): Path<Uuid>,
|
||||
Query(params): Query<crate::dtos::ErrorQuery>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
if user_id.value() != profile_user_uuid {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await;
|
||||
ctx.page_title = "Followers — Movies Diary".to_string();
|
||||
ctx.canonical_url = format!(
|
||||
"{}/users/{}/followers-list",
|
||||
state.app_ctx.config.base_url, profile_user_uuid
|
||||
);
|
||||
match state
|
||||
.ap_service
|
||||
.get_accepted_followers(user_id.value())
|
||||
.await
|
||||
{
|
||||
Ok(followers) => {
|
||||
let actors = followers
|
||||
.into_iter()
|
||||
.map(|a| RemoteActorView {
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
url: a.url,
|
||||
})
|
||||
.collect();
|
||||
let data = FollowersPageData {
|
||||
ctx,
|
||||
user_id: profile_user_uuid,
|
||||
actors,
|
||||
error: params.error,
|
||||
};
|
||||
match state.html_renderer.render_followers_page(data) {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("get_followers error: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to load followers list",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
pub async fn remove_follower(
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
State(state): State<AppState>,
|
||||
Path(profile_user_uuid): Path<Uuid>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<FollowerActionForm>,
|
||||
) -> impl IntoResponse {
|
||||
if user_id.value() != profile_user_uuid {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
match state
|
||||
.ap_service
|
||||
.remove_follower(user_id.value(), &form.actor_url)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Redirect::to(&format!("/users/{}/followers-list", profile_user_uuid))
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
let msg = encode_error(&e.to_string());
|
||||
Redirect::to(&format!(
|
||||
"/users/{}/followers-list?error={}",
|
||||
profile_user_uuid, msg
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
875
crates/presentation/src/handlers/import.rs
Normal file
875
crates/presentation/src/handlers/import.rs
Normal file
@@ -0,0 +1,875 @@
|
||||
use axum::{
|
||||
Extension, Form,
|
||||
extract::{Multipart, Path, State},
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Redirect},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use application::{
|
||||
commands::{
|
||||
ApplyImportMappingCommand, CreateImportSessionCommand, DeleteImportProfileCommand,
|
||||
ExecuteImportCommand, FileFormat, SaveImportProfileCommand,
|
||||
},
|
||||
ports::{
|
||||
ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView,
|
||||
ImportRowStatus, ImportUploadPageData,
|
||||
},
|
||||
use_cases::{
|
||||
apply_import_mapping, create_import_session, delete_import_profile, execute_import,
|
||||
list_import_profiles, save_import_profile,
|
||||
},
|
||||
};
|
||||
use domain::value_objects::ImportSessionId;
|
||||
use importer::{AnnotatedRow, DomainField, FieldMapping, RowResult, Transform};
|
||||
|
||||
use crate::{
|
||||
csrf::CsrfToken,
|
||||
extractors::{AuthenticatedUser, RequiredCookieUser},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
fn encode_error(msg: &str) -> String {
|
||||
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
|
||||
utf8_percent_encode(msg, NON_ALPHANUMERIC).to_string()
|
||||
}
|
||||
|
||||
fn str_to_domain_field(field: &str) -> Option<DomainField> {
|
||||
match field {
|
||||
"title" => Some(DomainField::Title),
|
||||
"release_year" => Some(DomainField::ReleaseYear),
|
||||
"director" => Some(DomainField::Director),
|
||||
"rating" => Some(DomainField::Rating),
|
||||
"watched_at" => Some(DomainField::WatchedAt),
|
||||
"comment" => Some(DomainField::Comment),
|
||||
"external_metadata_id" => Some(DomainField::ExternalMetadataId),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_mapping_form(form: &HashMap<String, String>) -> Vec<FieldMapping> {
|
||||
let mut mappings = Vec::new();
|
||||
let mut i = 0usize;
|
||||
loop {
|
||||
if i > 64 {
|
||||
break;
|
||||
}
|
||||
let col_key = format!("mapping_{i}_col");
|
||||
let Some(col) = form.get(&col_key).cloned() else {
|
||||
break;
|
||||
};
|
||||
let field_str = form
|
||||
.get(&format!("mapping_{i}_field"))
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("");
|
||||
if let Some(domain_field) = str_to_domain_field(field_str) {
|
||||
let transform = if domain_field == DomainField::Rating {
|
||||
let scale: f64 = form
|
||||
.get(&format!("mapping_{i}_scale"))
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(1.0);
|
||||
Transform::RatingScale(scale)
|
||||
} else if domain_field == DomainField::WatchedAt {
|
||||
form.get(&format!("mapping_{i}_datefmt"))
|
||||
.filter(|s| !s.is_empty())
|
||||
.cloned()
|
||||
.map(Transform::DateFormat)
|
||||
.unwrap_or(Transform::Identity)
|
||||
} else {
|
||||
Transform::Identity
|
||||
};
|
||||
mappings.push(FieldMapping {
|
||||
source_column: col,
|
||||
domain_field,
|
||||
transform,
|
||||
});
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
mappings
|
||||
}
|
||||
|
||||
fn annotated_to_preview_row(idx: usize, annotated: &AnnotatedRow) -> ImportPreviewRow {
|
||||
match &annotated.result {
|
||||
RowResult::Valid(row) => {
|
||||
let cells = vec![
|
||||
row.title.clone().unwrap_or_default(),
|
||||
row.release_year.clone().unwrap_or_default(),
|
||||
row.director.clone().unwrap_or_default(),
|
||||
row.rating.clone().unwrap_or_default(),
|
||||
row.watched_at.clone().unwrap_or_default(),
|
||||
row.comment.clone().unwrap_or_default(),
|
||||
];
|
||||
ImportPreviewRow {
|
||||
index: idx,
|
||||
status: if annotated.is_duplicate {
|
||||
ImportRowStatus::Duplicate
|
||||
} else {
|
||||
ImportRowStatus::Valid
|
||||
},
|
||||
cells,
|
||||
}
|
||||
}
|
||||
RowResult::Invalid { errors, raw } => ImportPreviewRow {
|
||||
index: idx,
|
||||
status: ImportRowStatus::Invalid(errors.join("; ")),
|
||||
cells: raw.iter().map(|(_, v)| v.clone()).collect(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTML wizard handlers ───────────────────────────────────────────────────
|
||||
|
||||
pub async fn get_import_page(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let ctx = super::html::build_page_context(&state, Some(user_id.clone()), csrf.0).await;
|
||||
let profiles = list_import_profiles::execute(&state.app_ctx, &user_id)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|p| ImportProfileView {
|
||||
id: p.id.value().to_string(),
|
||||
name: p.name,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let html = state
|
||||
.html_renderer
|
||||
.render_import_upload_page(ImportUploadPageData {
|
||||
ctx,
|
||||
profiles,
|
||||
error: None,
|
||||
})
|
||||
.unwrap_or_else(|e| e);
|
||||
Html(html)
|
||||
}
|
||||
|
||||
pub async fn post_upload(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
mut multipart: Multipart,
|
||||
) -> impl IntoResponse {
|
||||
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(bytes) = field.bytes().await {
|
||||
file_bytes = Some(bytes.to_vec());
|
||||
}
|
||||
}
|
||||
Some("format") => {
|
||||
if let Ok(text) = field.text().await {
|
||||
format_str = text;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let bytes = match file_bytes {
|
||||
Some(b) if !b.is_empty() => b,
|
||||
_ => return Redirect::to("/import?error=no+file+provided").into_response(),
|
||||
};
|
||||
|
||||
let format = match format_str.as_str() {
|
||||
"json" => FileFormat::Json,
|
||||
"xlsx" => FileFormat::Xlsx,
|
||||
_ => FileFormat::Csv,
|
||||
};
|
||||
|
||||
match create_import_session::execute(
|
||||
&state.app_ctx,
|
||||
CreateImportSessionCommand {
|
||||
user_id: user_id.value(),
|
||||
bytes,
|
||||
format,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => {
|
||||
Redirect::to(&format!("/import/{}/mapping", r.session_id.value())).into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!("/import?error={}", encode_error(&e.to_string())))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_mapping_page(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let Ok(session_id) = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
else {
|
||||
return Redirect::to("/import").into_response();
|
||||
};
|
||||
let Ok(Some(session)) = state
|
||||
.app_ctx
|
||||
.import_session_repository
|
||||
.get(&session_id, &user_id)
|
||||
.await
|
||||
else {
|
||||
return Redirect::to("/import").into_response();
|
||||
};
|
||||
let Ok(parsed) = serde_json::from_str::<importer::ParsedFile>(&session.parsed_data) else {
|
||||
return Redirect::to("/import").into_response();
|
||||
};
|
||||
|
||||
let ctx = super::html::build_page_context(&state, Some(user_id), csrf.0).await;
|
||||
let sample_rows = parsed.rows.into_iter().take(5).collect();
|
||||
let html = state
|
||||
.html_renderer
|
||||
.render_import_mapping_page(ImportMappingPageData {
|
||||
ctx,
|
||||
session_id: session_id_str,
|
||||
columns: parsed.columns,
|
||||
sample_rows,
|
||||
domain_fields: vec![
|
||||
("title", "Title"),
|
||||
("release_year", "Release Year"),
|
||||
("director", "Director"),
|
||||
("rating", "Rating"),
|
||||
("watched_at", "Watched At"),
|
||||
("comment", "Comment"),
|
||||
("external_metadata_id", "External ID"),
|
||||
],
|
||||
error: None,
|
||||
})
|
||||
.unwrap_or_else(|e| e);
|
||||
Html(html).into_response()
|
||||
}
|
||||
|
||||
pub async fn post_mapping(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<HashMap<String, String>>,
|
||||
) -> impl IntoResponse {
|
||||
let csrf_token = form.get("_csrf").map(|s| s.as_str()).unwrap_or("");
|
||||
if crate::csrf::mismatch(&csrf, csrf_token) {
|
||||
return Redirect::to("/import").into_response();
|
||||
}
|
||||
let Ok(session_id) = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
else {
|
||||
return Redirect::to("/import").into_response();
|
||||
};
|
||||
let mappings = parse_mapping_form(&form);
|
||||
if mappings.is_empty() {
|
||||
return Redirect::to(&format!(
|
||||
"/import/{}/mapping?error=select+at+least+one+mapping",
|
||||
session_id_str
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
match apply_import_mapping::execute(
|
||||
&state.app_ctx,
|
||||
ApplyImportMappingCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id: session_id.value(),
|
||||
mappings,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Redirect::to(&format!("/import/{}/preview", session_id_str)).into_response(),
|
||||
Err(e) => Redirect::to(&format!(
|
||||
"/import/{}/mapping?error={}",
|
||||
session_id_str,
|
||||
encode_error(&e.to_string())
|
||||
))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_preview_page(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
) -> impl IntoResponse {
|
||||
let Ok(session_id) = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
else {
|
||||
return Redirect::to("/import").into_response();
|
||||
};
|
||||
let Ok(Some(session)) = state
|
||||
.app_ctx
|
||||
.import_session_repository
|
||||
.get(&session_id, &user_id)
|
||||
.await
|
||||
else {
|
||||
return Redirect::to("/import").into_response();
|
||||
};
|
||||
|
||||
if session.row_results.is_none() {
|
||||
return Redirect::to(&format!("/import/{}/mapping", session_id_str)).into_response();
|
||||
}
|
||||
|
||||
let parsed =
|
||||
serde_json::from_str::<importer::ParsedFile>(&session.parsed_data).unwrap_or_default();
|
||||
let annotated: Vec<AnnotatedRow> = session
|
||||
.row_results
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let rows: Vec<ImportPreviewRow> = annotated
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, a)| annotated_to_preview_row(i, a))
|
||||
.collect();
|
||||
|
||||
let ctx = super::html::build_page_context(&state, Some(user_id), csrf.0).await;
|
||||
let html = state
|
||||
.html_renderer
|
||||
.render_import_preview_page(ImportPreviewPageData {
|
||||
ctx,
|
||||
session_id: session_id_str,
|
||||
columns: parsed.columns,
|
||||
rows,
|
||||
})
|
||||
.unwrap_or_else(|e| e);
|
||||
Html(html).into_response()
|
||||
}
|
||||
|
||||
pub async fn post_confirm(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Path(session_id_str): Path<String>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form_entries): Form<Vec<(String, String)>>,
|
||||
) -> impl IntoResponse {
|
||||
let csrf_token = form_entries
|
||||
.iter()
|
||||
.find(|(k, _)| k == "_csrf")
|
||||
.map(|(_, v)| v.as_str())
|
||||
.unwrap_or("");
|
||||
if crate::csrf::mismatch(&csrf, csrf_token) {
|
||||
return Redirect::to("/import").into_response();
|
||||
}
|
||||
let Ok(session_id) = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
else {
|
||||
return Redirect::to("/import").into_response();
|
||||
};
|
||||
|
||||
// Save profile if name provided
|
||||
let profile_name = form_entries
|
||||
.iter()
|
||||
.find(|(k, _)| k == "profile_name")
|
||||
.map(|(_, v)| v.clone())
|
||||
.filter(|n| !n.trim().is_empty());
|
||||
if let Some(name) = profile_name {
|
||||
let _ = save_import_profile::execute(
|
||||
&state.app_ctx,
|
||||
SaveImportProfileCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id: session_id.value(),
|
||||
name,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Collect all "confirmed" checkbox values
|
||||
let confirmed: Vec<usize> = form_entries
|
||||
.iter()
|
||||
.filter(|(k, _)| k == "confirmed")
|
||||
.filter_map(|(_, v)| v.parse::<usize>().ok())
|
||||
.collect();
|
||||
|
||||
match execute_import::execute(
|
||||
&state.app_ctx,
|
||||
ExecuteImportCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id: session_id.value(),
|
||||
confirmed_indices: confirmed,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(summary) => Redirect::to(&format!(
|
||||
"/import/done?imported={}&skipped={}&failed={}",
|
||||
summary.imported,
|
||||
summary.skipped_duplicates,
|
||||
summary.failed.len()
|
||||
))
|
||||
.into_response(),
|
||||
Err(e) => Redirect::to(&format!("/import?error={}", encode_error(&e.to_string())))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_delete_profile(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Path(profile_id_str): Path<String>,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
Form(form): Form<HashMap<String, String>>,
|
||||
) -> impl IntoResponse {
|
||||
let csrf_token = form.get("_csrf").map(|s| s.as_str()).unwrap_or("");
|
||||
if crate::csrf::mismatch(&csrf, csrf_token) {
|
||||
return Redirect::to("/import").into_response();
|
||||
}
|
||||
if let Ok(profile_id) = profile_id_str.parse::<uuid::Uuid>() {
|
||||
let _ = delete_import_profile::execute(
|
||||
&state.app_ctx,
|
||||
DeleteImportProfileCommand {
|
||||
user_id: user_id.value(),
|
||||
profile_id,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Redirect::to("/import").into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ImportDoneParams {
|
||||
pub imported: Option<usize>,
|
||||
pub skipped: Option<usize>,
|
||||
pub failed: Option<usize>,
|
||||
}
|
||||
|
||||
pub async fn get_import_done(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Extension(csrf): Extension<CsrfToken>,
|
||||
axum::extract::Query(params): axum::extract::Query<ImportDoneParams>,
|
||||
) -> impl IntoResponse {
|
||||
let _ctx = super::html::build_page_context(&state, Some(user_id.clone()), csrf.0).await;
|
||||
let html = format!(
|
||||
r#"<!doctype html><html><body>
|
||||
<h1>Import Complete</h1>
|
||||
<p>Imported: {}</p>
|
||||
<p>Skipped duplicates: {}</p>
|
||||
<p>Failed: {}</p>
|
||||
<a href="/users/{}">Go to My Profile</a>
|
||||
</body></html>"#,
|
||||
params.imported.unwrap_or(0),
|
||||
params.skipped.unwrap_or(0),
|
||||
params.failed.unwrap_or(0),
|
||||
user_id.value(),
|
||||
);
|
||||
Html(html)
|
||||
}
|
||||
|
||||
// ── REST API handlers ──────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
pub struct SessionCreatedResponse {
|
||||
pub session_id: String,
|
||||
pub columns: Vec<String>,
|
||||
pub sample_rows: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
) -> impl IntoResponse {
|
||||
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 (
|
||||
StatusCode::BAD_REQUEST,
|
||||
axum::Json(serde_json::json!({"error": "no file"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let format = match format_str.as_str() {
|
||||
"json" => FileFormat::Json,
|
||||
"xlsx" => FileFormat::Xlsx,
|
||||
_ => FileFormat::Csv,
|
||||
};
|
||||
match create_import_session::execute(
|
||||
&state.app_ctx,
|
||||
CreateImportSessionCommand {
|
||||
user_id: user_id.value(),
|
||||
bytes,
|
||||
format,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => axum::Json(SessionCreatedResponse {
|
||||
session_id: r.session_id.value().to_string(),
|
||||
columns: r.columns,
|
||||
sample_rows: r.sample_rows,
|
||||
})
|
||||
.into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
axum::Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
pub struct SessionStateResponse {
|
||||
pub session_id: String,
|
||||
pub columns: Vec<String>,
|
||||
pub has_mappings: bool,
|
||||
pub row_count: usize,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
) -> impl IntoResponse {
|
||||
let Ok(session_id) = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
axum::Json(serde_json::json!({"error": "invalid session id"})),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
match state
|
||||
.app_ctx
|
||||
.import_session_repository
|
||||
.get(&session_id, &user_id)
|
||||
.await
|
||||
{
|
||||
Ok(Some(session)) => {
|
||||
let parsed = serde_json::from_str::<importer::ParsedFile>(&session.parsed_data)
|
||||
.unwrap_or_default();
|
||||
let row_count = parsed.rows.len();
|
||||
axum::Json(SessionStateResponse {
|
||||
session_id: session_id_str,
|
||||
columns: parsed.columns,
|
||||
has_mappings: session.field_mappings.is_some(),
|
||||
row_count,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Ok(None) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
axum::Json(serde_json::json!({"error": "session not found"})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
axum::Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct ApiFieldMapping {
|
||||
/// Column name in the source file
|
||||
pub source_column: String,
|
||||
/// Domain field: title | release_year | director | rating | watched_at | comment | external_metadata_id
|
||||
pub domain_field: String,
|
||||
/// For rating fields: multiply raw value by this factor (e.g. 0.5 for 10-point → 5-point scale)
|
||||
pub rating_scale: Option<f64>,
|
||||
/// For watched_at fields: strftime format hint (e.g. "%d/%m/%Y")
|
||||
pub date_format: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct ApplyMappingRequest {
|
||||
pub mappings: Vec<ApiFieldMapping>,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
) -> impl IntoResponse {
|
||||
let Ok(session_id) = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
axum::Json(serde_json::json!({"error": "invalid session id"})),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
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();
|
||||
|
||||
match apply_import_mapping::execute(
|
||||
&state.app_ctx,
|
||||
ApplyImportMappingCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id: session_id.value(),
|
||||
mappings,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(rows) => axum::Json(serde_json::json!({"row_count": rows.len()})).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
axum::Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct ConfirmRequest {
|
||||
/// Indices (0-based) of rows from the mapping preview to import
|
||||
pub confirmed_indices: Vec<usize>,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
) -> impl IntoResponse {
|
||||
let Ok(session_id) = session_id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
axum::Json(serde_json::json!({"error": "invalid session id"})),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
match execute_import::execute(&state.app_ctx, ExecuteImportCommand { user_id: user_id.value(), session_id: session_id.value(), confirmed_indices: body.confirmed_indices }).await {
|
||||
Ok(s) => 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<_>>(),
|
||||
})).into_response(),
|
||||
Err(e) => {
|
||||
let status = if matches!(e, domain::errors::DomainError::NotFound(_)) {
|
||||
StatusCode::NOT_FOUND
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
};
|
||||
(status, axum::Json(serde_json::json!({"error": e.to_string()}))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
) -> impl IntoResponse {
|
||||
match list_import_profiles::execute(&state.app_ctx, &user_id).await {
|
||||
Ok(profiles) => 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<_>>(),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
axum::Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, utoipa::ToSchema)]
|
||||
pub struct SaveProfileRequest {
|
||||
/// Session UUID whose current field_mappings to save
|
||||
pub session_id: String,
|
||||
/// Human-readable profile name (e.g. "Letterboxd")
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
) -> impl IntoResponse {
|
||||
let Ok(session_id) = body
|
||||
.session_id
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(ImportSessionId::from_uuid)
|
||||
else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
axum::Json(serde_json::json!({"error": "invalid session id"})),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
match save_import_profile::execute(
|
||||
&state.app_ctx,
|
||||
SaveImportProfileCommand {
|
||||
user_id: user_id.value(),
|
||||
session_id: session_id.value(),
|
||||
name: body.name,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(id) => axum::Json(serde_json::json!({"id": id.value().to_string()})).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
axum::Json(serde_json::json!({"error": e.to_string()})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[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>,
|
||||
) -> impl IntoResponse {
|
||||
let Ok(profile_id) = profile_id_str.parse::<uuid::Uuid>() else {
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
};
|
||||
match delete_import_profile::execute(
|
||||
&state.app_ctx,
|
||||
DeleteImportProfileCommand {
|
||||
user_id: user_id.value(),
|
||||
profile_id,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => {
|
||||
let status = if matches!(e, domain::errors::DomainError::NotFound(_)) {
|
||||
StatusCode::NOT_FOUND
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
};
|
||||
status.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
8
crates/presentation/src/handlers/mod.rs
Normal file
8
crates/presentation/src/handlers/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
pub mod html;
|
||||
pub mod posters;
|
||||
pub mod rss;
|
||||
pub mod api;
|
||||
pub mod import;
|
||||
|
||||
const DEFAULT_PAGE_LIMIT: u32 = 5;
|
||||
const RSS_FEED_LIMIT: u32 = 50;
|
||||
33
crates/presentation/src/handlers/posters.rs
Normal file
33
crates/presentation/src/handlers/posters.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{StatusCode, header},
|
||||
response::IntoResponse,
|
||||
};
|
||||
|
||||
use domain::value_objects::PosterPath;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn get_poster(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
// If path is a remote URL, redirect directly instead of serving from local storage.
|
||||
if path.starts_with("http://") || path.starts_with("https://") {
|
||||
return axum::response::Redirect::temporary(&path).into_response();
|
||||
}
|
||||
|
||||
let poster_path = match PosterPath::new(path) {
|
||||
Ok(p) => p,
|
||||
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
match state.app_ctx.poster_storage.get_poster(&poster_path).await {
|
||||
Ok(bytes) => {
|
||||
let mime = infer::get(&bytes)
|
||||
.map(|t| t.mime_type())
|
||||
.unwrap_or("application/octet-stream");
|
||||
([(header::CONTENT_TYPE, mime)], bytes).into_response()
|
||||
}
|
||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
65
crates/presentation/src/handlers/rss.rs
Normal file
65
crates/presentation/src/handlers/rss.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::header,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{queries::GetDiaryQuery, use_cases::get_diary};
|
||||
use domain::{errors::DomainError, models::SortDirection, value_objects::UserId};
|
||||
|
||||
use crate::{errors::ApiError, state::AppState};
|
||||
|
||||
pub async fn get_feed(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
|
||||
let query = GetDiaryQuery {
|
||||
limit: Some(super::RSS_FEED_LIMIT),
|
||||
offset: Some(0),
|
||||
sort_by: Some(SortDirection::Descending),
|
||||
movie_id: None,
|
||||
user_id: None,
|
||||
};
|
||||
let page = get_diary::execute(&state.app_ctx, query).await?;
|
||||
let xml = state
|
||||
.rss_renderer
|
||||
.render_feed(&page.items, "Movie Diary")
|
||||
.map_err(|e| ApiError(DomainError::InfrastructureError(e)))?;
|
||||
Ok((
|
||||
[(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8")],
|
||||
xml,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_user_feed(
|
||||
State(state): State<AppState>,
|
||||
Path(user_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let user = state
|
||||
.app_ctx
|
||||
.user_repository
|
||||
.find_by_id(&UserId::from_uuid(user_id))
|
||||
.await
|
||||
.map_err(ApiError)?
|
||||
.ok_or_else(|| ApiError(DomainError::NotFound(format!("User {user_id}"))))?;
|
||||
|
||||
let query = GetDiaryQuery {
|
||||
limit: Some(super::RSS_FEED_LIMIT),
|
||||
offset: Some(0),
|
||||
sort_by: Some(SortDirection::Descending),
|
||||
movie_id: None,
|
||||
user_id: Some(user_id),
|
||||
};
|
||||
let page = get_diary::execute(&state.app_ctx, query).await?;
|
||||
|
||||
let display_name = user.email().value().split('@').next().unwrap_or("User");
|
||||
let title = format!("{}'s Movie Diary", display_name);
|
||||
|
||||
let xml = state
|
||||
.rss_renderer
|
||||
.render_feed(&page.items, &title)
|
||||
.map_err(|e| ApiError(DomainError::InfrastructureError(e)))?;
|
||||
|
||||
Ok((
|
||||
[(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8")],
|
||||
xml,
|
||||
))
|
||||
}
|
||||
@@ -14,7 +14,7 @@ use doc::ApiDocExt;
|
||||
use presentation::{openapi::ApiDoc, routes, state::AppState};
|
||||
use utoipa::OpenApi as _;
|
||||
|
||||
use domain::ports::{DiaryExporter, EventPublisher};
|
||||
use domain::ports::{DiaryExporter, EventPublisher, ImportProfileRepository, ImportSessionRepository};
|
||||
|
||||
#[cfg(not(any(feature = "sqlite", feature = "postgres")))]
|
||||
compile_error!("At least one database backend must be enabled. Use --features sqlite or --features postgres");
|
||||
@@ -50,17 +50,17 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
let poster_fetcher = poster_fetcher::create()?;
|
||||
let poster_storage = poster_storage::create()?;
|
||||
|
||||
let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, db_pool) =
|
||||
let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, import_session_repository, import_profile_repository, db_pool) =
|
||||
match backend.as_str() {
|
||||
#[cfg(feature = "postgres")]
|
||||
"postgres" => {
|
||||
let (pool, m, r, d, s, u) = postgres::wire(&database_url).await?;
|
||||
(m, r, d, s, u, DbPool::Postgres(pool))
|
||||
let (pool, m, r, d, s, u, is, ip) = postgres::wire(&database_url).await?;
|
||||
(m, r, d, s, u, is, ip, DbPool::Postgres(pool))
|
||||
}
|
||||
#[cfg(feature = "sqlite")]
|
||||
_ => {
|
||||
let (pool, m, r, d, s, u) = sqlite::wire(&database_url).await?;
|
||||
(m, r, d, s, u, DbPool::Sqlite(pool))
|
||||
let (pool, m, r, d, s, u, is, ip) = sqlite::wire(&database_url).await?;
|
||||
(m, r, d, s, u, is, ip, DbPool::Sqlite(pool))
|
||||
}
|
||||
#[cfg(not(feature = "sqlite"))]
|
||||
_ => anyhow::bail!("DATABASE_BACKEND={backend} is not supported by this build (sqlite feature is not enabled)"),
|
||||
@@ -158,6 +158,8 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
auth_service,
|
||||
password_hasher,
|
||||
user_repository,
|
||||
import_session_repository: import_session_repository as Arc<dyn ImportSessionRepository>,
|
||||
import_profile_repository: import_profile_repository as Arc<dyn ImportProfileRepository>,
|
||||
config: app_config,
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ use crate::dtos::{
|
||||
ReviewHistoryResponse, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto,
|
||||
UsersResponse,
|
||||
};
|
||||
use crate::handlers::import::{
|
||||
ApiFieldMapping, ApplyMappingRequest, ConfirmRequest, SaveProfileRequest,
|
||||
SessionCreatedResponse, SessionStateResponse,
|
||||
};
|
||||
#[cfg(feature = "federation")]
|
||||
use crate::dtos::{ActorListResponse, ActorUrlRequest, FollowRequest, RemoteActorDto};
|
||||
|
||||
@@ -45,6 +49,13 @@ impl Modify for SecurityAddon {
|
||||
crate::handlers::api::get_activity_feed,
|
||||
crate::handlers::api::list_users,
|
||||
crate::handlers::api::get_user_profile,
|
||||
crate::handlers::import::api_post_session,
|
||||
crate::handlers::import::api_get_session,
|
||||
crate::handlers::import::api_put_mapping,
|
||||
crate::handlers::import::api_post_confirm,
|
||||
crate::handlers::import::api_get_profiles,
|
||||
crate::handlers::import::api_post_profile,
|
||||
crate::handlers::import::api_delete_profile,
|
||||
),
|
||||
components(schemas(
|
||||
DiaryResponse,
|
||||
@@ -66,6 +77,12 @@ impl Modify for SecurityAddon {
|
||||
MonthlyRatingDto,
|
||||
DirectorStatDto,
|
||||
UserTrendsDto,
|
||||
SessionCreatedResponse,
|
||||
SessionStateResponse,
|
||||
ApiFieldMapping,
|
||||
ApplyMappingRequest,
|
||||
ConfirmRequest,
|
||||
SaveProfileRequest,
|
||||
)),
|
||||
modifiers(&SecurityAddon),
|
||||
)]
|
||||
@@ -99,6 +116,13 @@ pub struct ApiDoc;
|
||||
crate::handlers::api::accept_follower,
|
||||
crate::handlers::api::reject_follower,
|
||||
crate::handlers::api::remove_follower,
|
||||
crate::handlers::import::api_post_session,
|
||||
crate::handlers::import::api_get_session,
|
||||
crate::handlers::import::api_put_mapping,
|
||||
crate::handlers::import::api_post_confirm,
|
||||
crate::handlers::import::api_get_profiles,
|
||||
crate::handlers::import::api_post_profile,
|
||||
crate::handlers::import::api_delete_profile,
|
||||
),
|
||||
components(schemas(
|
||||
DiaryResponse,
|
||||
@@ -124,6 +148,12 @@ pub struct ApiDoc;
|
||||
MonthlyRatingDto,
|
||||
DirectorStatDto,
|
||||
UserTrendsDto,
|
||||
SessionCreatedResponse,
|
||||
SessionStateResponse,
|
||||
ApiFieldMapping,
|
||||
ApplyMappingRequest,
|
||||
ConfirmRequest,
|
||||
SaveProfileRequest,
|
||||
)),
|
||||
modifiers(&SecurityAddon),
|
||||
)]
|
||||
|
||||
@@ -65,17 +65,23 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
|
||||
routing::get(handlers::posters::get_poster),
|
||||
)
|
||||
.route("/diary/export", routing::get(handlers::html::get_export))
|
||||
.route("/import", routing::get(handlers::import::get_import_page))
|
||||
.route("/import/upload", routing::post(handlers::import::post_upload))
|
||||
.route("/import/{id}/mapping", routing::get(handlers::import::get_mapping_page).post(handlers::import::post_mapping))
|
||||
.route("/import/{id}/preview", routing::get(handlers::import::get_preview_page))
|
||||
.route("/import/{id}/confirm", routing::post(handlers::import::post_confirm))
|
||||
.route("/import/done", routing::get(handlers::import::get_import_done))
|
||||
.route("/import/profiles/{profile_id}/delete", routing::post(handlers::import::post_delete_profile))
|
||||
.route("/feed.rss", routing::get(handlers::rss::get_feed))
|
||||
.route(
|
||||
"/users/{id}/feed.rss",
|
||||
routing::get(handlers::rss::get_user_feed),
|
||||
)
|
||||
.layer(axum::middleware::from_fn(crate::csrf::csrf_middleware));
|
||||
);
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
let base = base.merge(federation_html_routes());
|
||||
|
||||
base
|
||||
base.layer(axum::middleware::from_fn(crate::csrf::csrf_middleware))
|
||||
}
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
@@ -142,7 +148,13 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
|
||||
routing::get(handlers::api::get_activity_feed),
|
||||
)
|
||||
.route("/users", routing::get(handlers::api::list_users))
|
||||
.route("/users/{id}", routing::get(handlers::api::get_user_profile));
|
||||
.route("/users/{id}", routing::get(handlers::api::get_user_profile))
|
||||
.route("/import/sessions", routing::post(handlers::import::api_post_session))
|
||||
.route("/import/sessions/{id}", routing::get(handlers::import::api_get_session))
|
||||
.route("/import/sessions/{id}/mapping", routing::put(handlers::import::api_put_mapping))
|
||||
.route("/import/sessions/{id}/confirm", routing::post(handlers::import::api_post_confirm))
|
||||
.route("/import/profiles", routing::get(handlers::import::api_get_profiles).post(handlers::import::api_post_profile))
|
||||
.route("/import/profiles/{id}", routing::delete(handlers::import::api_delete_profile));
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
let base = base.merge(federation_api_routes());
|
||||
|
||||
Reference in New Issue
Block a user