fix: unmap status mappings instead of deleting, store unmapped provider statuses

- Make mapped_status nullable so unmapping (X button) sets NULL instead of
  deleting the row — provider statuses never disappear from the UI
- normalize_series_status now returns the raw provider status (lowercased)
  when no mapping exists, so all statuses are stored in series_metadata
- Fix series_statuses query crash caused by NULL mapped_status values
- Fix metadata batch/refresh server actions crashing page on 400 errors
- StatusMappingDto.mapped_status is now string | null in the backoffice

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 13:22:31 +01:00
parent d4c48de780
commit c44b51d6ef
11 changed files with 109 additions and 68 deletions

View File

@@ -870,7 +870,7 @@ pub async fn series_statuses(
r#"SELECT DISTINCT s FROM (
SELECT LOWER(status) AS s FROM series_metadata WHERE status IS NOT NULL
UNION
SELECT mapped_status AS s FROM status_mappings
SELECT mapped_status AS s FROM status_mappings WHERE mapped_status IS NOT NULL
) t ORDER BY s"#,
)
.fetch_all(&state.pool)

View File

@@ -694,7 +694,7 @@ pub(crate) async fn sync_series_metadata(
.and_then(|y| y.as_i64())
.map(|y| y as i32);
let status = if let Some(raw) = metadata_json.get("status").and_then(|s| s.as_str()) {
normalize_series_status(&state.pool, raw).await
Some(normalize_series_status(&state.pool, raw).await)
} else {
None
};
@@ -802,7 +802,7 @@ pub(crate) async fn sync_series_metadata(
FieldDef {
name: "status",
old: existing.as_ref().and_then(|r| r.get::<Option<String>, _>("status")).map(serde_json::Value::String),
new: status.as_ref().map(|s| serde_json::Value::String(s.clone())),
new: status.as_ref().map(|s: &String| serde_json::Value::String(s.clone())),
},
];
@@ -828,33 +828,33 @@ pub(crate) async fn sync_series_metadata(
/// Normalize provider-specific status strings using the status_mappings table.
/// Returns None if no mapping is found — unknown statuses are not stored.
pub(crate) async fn normalize_series_status(pool: &sqlx::PgPool, raw: &str) -> Option<String> {
pub(crate) async fn normalize_series_status(pool: &sqlx::PgPool, raw: &str) -> String {
let lower = raw.to_lowercase();
// Try exact match first
// Try exact match first (only mapped entries)
if let Ok(Some(row)) = sqlx::query_scalar::<_, String>(
"SELECT mapped_status FROM status_mappings WHERE provider_status = $1",
"SELECT mapped_status FROM status_mappings WHERE provider_status = $1 AND mapped_status IS NOT NULL",
)
.bind(&lower)
.fetch_optional(pool)
.await
{
return Some(row);
return row;
}
// Try substring match (for Bédéthèque-style statuses like "Série finie")
if let Ok(Some(row)) = sqlx::query_scalar::<_, String>(
"SELECT mapped_status FROM status_mappings WHERE $1 LIKE '%' || provider_status || '%' LIMIT 1",
"SELECT mapped_status FROM status_mappings WHERE $1 LIKE '%' || provider_status || '%' AND mapped_status IS NOT NULL LIMIT 1",
)
.bind(&lower)
.fetch_optional(pool)
.await
{
return Some(row);
return row;
}
// No mapping found — don't store unknown statuses
None
// No mapping found — return the provider status as-is (lowercased)
lower
}
pub(crate) async fn sync_books_metadata(

View File

@@ -772,7 +772,7 @@ async fn sync_series_from_candidate(
let start_year = candidate.start_year;
let total_volumes = candidate.total_volumes;
let status = if let Some(raw) = candidate.metadata_json.get("status").and_then(|s| s.as_str()) {
crate::metadata::normalize_series_status(pool, raw).await
Some(crate::metadata::normalize_series_status(pool, raw).await)
} else {
None
};

View File

@@ -575,7 +575,7 @@ async fn sync_series_with_diff(
let new_start_year = candidate.start_year;
let new_total_volumes = candidate.total_volumes;
let new_status = if let Some(raw) = candidate.metadata_json.get("status").and_then(|s| s.as_str()) {
crate::metadata::normalize_series_status(pool, raw).await
Some(crate::metadata::normalize_series_status(pool, raw).await)
} else {
None
};

View File

@@ -342,7 +342,7 @@ pub async fn get_thumbnail_stats(State(_state): State<AppState>) -> Result<Json<
pub struct StatusMappingDto {
pub id: String,
pub provider_status: String,
pub mapped_status: String,
pub mapped_status: Option<String>,
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
@@ -366,7 +366,7 @@ pub async fn list_status_mappings(
State(state): State<AppState>,
) -> Result<Json<Vec<StatusMappingDto>>, ApiError> {
let rows = sqlx::query(
"SELECT id, provider_status, mapped_status FROM status_mappings ORDER BY mapped_status, provider_status",
"SELECT id, provider_status, mapped_status FROM status_mappings ORDER BY mapped_status NULLS LAST, provider_status",
)
.fetch_all(&state.pool)
.await?;
@@ -376,7 +376,7 @@ pub async fn list_status_mappings(
.map(|row| StatusMappingDto {
id: row.get::<Uuid, _>("id").to_string(),
provider_status: row.get("provider_status"),
mapped_status: row.get("mapped_status"),
mapped_status: row.get::<Option<String>, _>("mapped_status"),
})
.collect();
@@ -418,18 +418,18 @@ pub async fn upsert_status_mapping(
Ok(Json(StatusMappingDto {
id: row.get::<Uuid, _>("id").to_string(),
provider_status: row.get("provider_status"),
mapped_status: row.get("mapped_status"),
mapped_status: row.get::<Option<String>, _>("mapped_status"),
}))
}
/// Delete a status mapping
/// Unmap a status mapping (sets mapped_status to NULL, keeps the provider status known)
#[utoipa::path(
delete,
path = "/settings/status-mappings/{id}",
tag = "settings",
params(("id" = String, Path, description = "Mapping UUID")),
responses(
(status = 204, description = "Deleted"),
(status = 200, body = StatusMappingDto),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
),
@@ -438,15 +438,20 @@ pub async fn upsert_status_mapping(
pub async fn delete_status_mapping(
State(state): State<AppState>,
AxumPath(id): AxumPath<Uuid>,
) -> Result<Json<Value>, ApiError> {
let result = sqlx::query("DELETE FROM status_mappings WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await?;
) -> Result<Json<StatusMappingDto>, ApiError> {
let row = sqlx::query(
"UPDATE status_mappings SET mapped_status = NULL, updated_at = NOW() WHERE id = $1 RETURNING id, provider_status, mapped_status",
)
.bind(id)
.fetch_optional(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::not_found("status mapping not found"));
match row {
Some(row) => Ok(Json(StatusMappingDto {
id: row.get::<Uuid, _>("id").to_string(),
provider_status: row.get("provider_status"),
mapped_status: row.get::<Option<String>, _>("mapped_status"),
})),
None => Err(ApiError::not_found("status mapping not found")),
}
Ok(Json(serde_json::json!({"deleted": true})))
}