importer feature

This commit is contained in:
2026-05-10 21:23:56 +02:00
parent a47e3ae4e6
commit f2f1317660
77 changed files with 4884 additions and 1810 deletions

View 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()
}
}
}