importer feature
This commit is contained in:
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user