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:
@@ -870,7 +870,7 @@ pub async fn series_statuses(
|
|||||||
r#"SELECT DISTINCT s FROM (
|
r#"SELECT DISTINCT s FROM (
|
||||||
SELECT LOWER(status) AS s FROM series_metadata WHERE status IS NOT NULL
|
SELECT LOWER(status) AS s FROM series_metadata WHERE status IS NOT NULL
|
||||||
UNION
|
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"#,
|
) t ORDER BY s"#,
|
||||||
)
|
)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
|
|||||||
@@ -694,7 +694,7 @@ pub(crate) async fn sync_series_metadata(
|
|||||||
.and_then(|y| y.as_i64())
|
.and_then(|y| y.as_i64())
|
||||||
.map(|y| y as i32);
|
.map(|y| y as i32);
|
||||||
let status = if let Some(raw) = metadata_json.get("status").and_then(|s| s.as_str()) {
|
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 {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@@ -802,7 +802,7 @@ pub(crate) async fn sync_series_metadata(
|
|||||||
FieldDef {
|
FieldDef {
|
||||||
name: "status",
|
name: "status",
|
||||||
old: existing.as_ref().and_then(|r| r.get::<Option<String>, _>("status")).map(serde_json::Value::String),
|
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.
|
/// Normalize provider-specific status strings using the status_mappings table.
|
||||||
/// Returns None if no mapping is found — unknown statuses are not stored.
|
/// 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();
|
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>(
|
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)
|
.bind(&lower)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
return Some(row);
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try substring match (for Bédéthèque-style statuses like "Série finie")
|
// Try substring match (for Bédéthèque-style statuses like "Série finie")
|
||||||
if let Ok(Some(row)) = sqlx::query_scalar::<_, String>(
|
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)
|
.bind(&lower)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
return Some(row);
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No mapping found — don't store unknown statuses
|
// No mapping found — return the provider status as-is (lowercased)
|
||||||
None
|
lower
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn sync_books_metadata(
|
pub(crate) async fn sync_books_metadata(
|
||||||
|
|||||||
@@ -772,7 +772,7 @@ async fn sync_series_from_candidate(
|
|||||||
let start_year = candidate.start_year;
|
let start_year = candidate.start_year;
|
||||||
let total_volumes = candidate.total_volumes;
|
let total_volumes = candidate.total_volumes;
|
||||||
let status = if let Some(raw) = candidate.metadata_json.get("status").and_then(|s| s.as_str()) {
|
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 {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -575,7 +575,7 @@ async fn sync_series_with_diff(
|
|||||||
let new_start_year = candidate.start_year;
|
let new_start_year = candidate.start_year;
|
||||||
let new_total_volumes = candidate.total_volumes;
|
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()) {
|
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 {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ pub async fn get_thumbnail_stats(State(_state): State<AppState>) -> Result<Json<
|
|||||||
pub struct StatusMappingDto {
|
pub struct StatusMappingDto {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub provider_status: String,
|
pub provider_status: String,
|
||||||
pub mapped_status: String,
|
pub mapped_status: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||||
@@ -366,7 +366,7 @@ pub async fn list_status_mappings(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Result<Json<Vec<StatusMappingDto>>, ApiError> {
|
) -> Result<Json<Vec<StatusMappingDto>>, ApiError> {
|
||||||
let rows = sqlx::query(
|
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)
|
.fetch_all(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -376,7 +376,7 @@ pub async fn list_status_mappings(
|
|||||||
.map(|row| StatusMappingDto {
|
.map(|row| StatusMappingDto {
|
||||||
id: row.get::<Uuid, _>("id").to_string(),
|
id: row.get::<Uuid, _>("id").to_string(),
|
||||||
provider_status: row.get("provider_status"),
|
provider_status: row.get("provider_status"),
|
||||||
mapped_status: row.get("mapped_status"),
|
mapped_status: row.get::<Option<String>, _>("mapped_status"),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -418,18 +418,18 @@ pub async fn upsert_status_mapping(
|
|||||||
Ok(Json(StatusMappingDto {
|
Ok(Json(StatusMappingDto {
|
||||||
id: row.get::<Uuid, _>("id").to_string(),
|
id: row.get::<Uuid, _>("id").to_string(),
|
||||||
provider_status: row.get("provider_status"),
|
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(
|
#[utoipa::path(
|
||||||
delete,
|
delete,
|
||||||
path = "/settings/status-mappings/{id}",
|
path = "/settings/status-mappings/{id}",
|
||||||
tag = "settings",
|
tag = "settings",
|
||||||
params(("id" = String, Path, description = "Mapping UUID")),
|
params(("id" = String, Path, description = "Mapping UUID")),
|
||||||
responses(
|
responses(
|
||||||
(status = 204, description = "Deleted"),
|
(status = 200, body = StatusMappingDto),
|
||||||
(status = 401, description = "Unauthorized"),
|
(status = 401, description = "Unauthorized"),
|
||||||
(status = 404, description = "Not found"),
|
(status = 404, description = "Not found"),
|
||||||
),
|
),
|
||||||
@@ -438,15 +438,20 @@ pub async fn upsert_status_mapping(
|
|||||||
pub async fn delete_status_mapping(
|
pub async fn delete_status_mapping(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AxumPath(id): AxumPath<Uuid>,
|
AxumPath(id): AxumPath<Uuid>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<StatusMappingDto>, ApiError> {
|
||||||
let result = sqlx::query("DELETE FROM status_mappings WHERE id = $1")
|
let row = sqlx::query(
|
||||||
.bind(id)
|
"UPDATE status_mappings SET mapped_status = NULL, updated_at = NOW() WHERE id = $1 RETURNING id, provider_status, mapped_status",
|
||||||
.execute(&state.pool)
|
)
|
||||||
.await?;
|
.bind(id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
if result.rows_affected() == 0 {
|
match row {
|
||||||
return Err(ApiError::not_found("status mapping not found"));
|
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})))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,13 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
|||||||
"use server";
|
"use server";
|
||||||
const libraryId = formData.get("library_id") as string;
|
const libraryId = formData.get("library_id") as string;
|
||||||
if (!libraryId) return;
|
if (!libraryId) return;
|
||||||
const result = await startMetadataBatch(libraryId);
|
let result;
|
||||||
|
try {
|
||||||
|
result = await startMetadataBatch(libraryId);
|
||||||
|
} catch {
|
||||||
|
// Library may have metadata disabled — ignore silently
|
||||||
|
return;
|
||||||
|
}
|
||||||
revalidatePath("/jobs");
|
revalidatePath("/jobs");
|
||||||
redirect(`/jobs?highlight=${result.id}`);
|
redirect(`/jobs?highlight=${result.id}`);
|
||||||
}
|
}
|
||||||
@@ -62,7 +68,12 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
|||||||
"use server";
|
"use server";
|
||||||
const libraryId = formData.get("library_id") as string;
|
const libraryId = formData.get("library_id") as string;
|
||||||
if (!libraryId) return;
|
if (!libraryId) return;
|
||||||
const result = await startMetadataRefresh(libraryId);
|
let result;
|
||||||
|
try {
|
||||||
|
result = await startMetadataRefresh(libraryId);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
revalidatePath("/jobs");
|
revalidatePath("/jobs");
|
||||||
redirect(`/jobs?highlight=${result.id}`);
|
redirect(`/jobs?highlight=${result.id}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,12 @@ export default async function LibrariesPage() {
|
|||||||
async function batchMetadataAction(formData: FormData) {
|
async function batchMetadataAction(formData: FormData) {
|
||||||
"use server";
|
"use server";
|
||||||
const id = formData.get("id") as string;
|
const id = formData.get("id") as string;
|
||||||
await startMetadataBatch(id);
|
try {
|
||||||
|
await startMetadataBatch(id);
|
||||||
|
} catch {
|
||||||
|
// Library may have metadata disabled — ignore silently
|
||||||
|
return;
|
||||||
|
}
|
||||||
revalidatePath("/libraries");
|
revalidatePath("/libraries");
|
||||||
revalidatePath("/jobs");
|
revalidatePath("/jobs");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1023,26 +1023,39 @@ function StatusMappingsCard() {
|
|||||||
|
|
||||||
useEffect(() => { loadData(); }, [loadData]);
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
|
|
||||||
// Group mappings by target status
|
// Group mappings by target status (only those with a non-null mapped_status)
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const map = new Map<string, StatusMappingDto[]>();
|
const map = new Map<string, StatusMappingDto[]>();
|
||||||
for (const m of mappings) {
|
for (const m of mappings) {
|
||||||
const list = map.get(m.mapped_status) || [];
|
if (m.mapped_status) {
|
||||||
list.push(m);
|
const list = map.get(m.mapped_status) || [];
|
||||||
map.set(m.mapped_status, list);
|
list.push(m);
|
||||||
|
map.set(m.mapped_status, list);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [mappings]);
|
}, [mappings]);
|
||||||
|
|
||||||
// Provider statuses not yet mapped
|
// Unmapped = mappings with null mapped_status + provider statuses not in status_mappings at all
|
||||||
const mappedProviderStatuses = useMemo(
|
const knownProviderStatuses = useMemo(
|
||||||
() => new Set(mappings.map((m) => m.provider_status)),
|
() => new Set(mappings.map((m) => m.provider_status)),
|
||||||
[mappings],
|
[mappings],
|
||||||
);
|
);
|
||||||
const unmappedProviderStatuses = useMemo(
|
const unmappedMappings = useMemo(
|
||||||
() => providerStatuses.filter((ps) => !mappedProviderStatuses.has(ps)),
|
() => mappings.filter((m) => !m.mapped_status),
|
||||||
[providerStatuses, mappedProviderStatuses],
|
[mappings],
|
||||||
);
|
);
|
||||||
|
const newProviderStatuses = useMemo(
|
||||||
|
() => providerStatuses.filter((ps) => !knownProviderStatuses.has(ps)),
|
||||||
|
[providerStatuses, knownProviderStatuses],
|
||||||
|
);
|
||||||
|
|
||||||
|
// All possible targets = existing statuses from DB + custom ones added locally
|
||||||
|
const [customTargets, setCustomTargets] = useState<string[]>([]);
|
||||||
|
const allTargets = useMemo(() => {
|
||||||
|
const set = new Set([...targetStatuses, ...customTargets]);
|
||||||
|
return [...set].sort();
|
||||||
|
}, [targetStatuses, customTargets]);
|
||||||
|
|
||||||
async function handleAssign(providerStatus: string, targetStatus: string) {
|
async function handleAssign(providerStatus: string, targetStatus: string) {
|
||||||
if (!providerStatus || !targetStatus) return;
|
if (!providerStatus || !targetStatus) return;
|
||||||
@@ -1061,27 +1074,12 @@ function StatusMappingsCard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
async function handleUnmap(id: string) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/settings/status-mappings/${id}`, { method: "DELETE" });
|
const res = await fetch(`/api/settings/status-mappings/${id}`, { method: "DELETE" });
|
||||||
if (res.ok) {
|
|
||||||
setMappings((prev) => prev.filter((m) => m.id !== id));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleChangeTarget(mapping: StatusMappingDto, newTarget: string) {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/settings/status-mappings", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ provider_status: mapping.provider_status, mapped_status: newTarget }),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const updated: StatusMappingDto = await res.json();
|
const updated: StatusMappingDto = await res.json();
|
||||||
setMappings((prev) => prev.map((m) => (m.id === mapping.id ? updated : m)));
|
setMappings((prev) => prev.map((m) => (m.id === id ? updated : m)));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -1090,8 +1088,8 @@ function StatusMappingsCard() {
|
|||||||
|
|
||||||
function handleCreateTarget() {
|
function handleCreateTarget() {
|
||||||
const name = newTargetName.trim().toLowerCase();
|
const name = newTargetName.trim().toLowerCase();
|
||||||
if (!name || targetStatuses.includes(name)) return;
|
if (!name || allTargets.includes(name)) return;
|
||||||
setTargetStatuses((prev) => [...prev, name].sort());
|
setCustomTargets((prev) => [...prev, name]);
|
||||||
setNewTargetName("");
|
setNewTargetName("");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1131,7 +1129,7 @@ function StatusMappingsCard() {
|
|||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreateTarget}
|
onClick={handleCreateTarget}
|
||||||
disabled={!newTargetName.trim() || targetStatuses.includes(newTargetName.trim().toLowerCase())}
|
disabled={!newTargetName.trim() || allTargets.includes(newTargetName.trim().toLowerCase())}
|
||||||
>
|
>
|
||||||
<Icon name="plus" size="sm" />
|
<Icon name="plus" size="sm" />
|
||||||
{t("settings.createTargetStatus")}
|
{t("settings.createTargetStatus")}
|
||||||
@@ -1139,7 +1137,7 @@ function StatusMappingsCard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grouped by target status */}
|
{/* Grouped by target status */}
|
||||||
{targetStatuses.map((target) => {
|
{allTargets.map((target) => {
|
||||||
const items = grouped.get(target) || [];
|
const items = grouped.get(target) || [];
|
||||||
return (
|
return (
|
||||||
<div key={target} className="border border-border/50 rounded-lg p-3">
|
<div key={target} className="border border-border/50 rounded-lg p-3">
|
||||||
@@ -1158,7 +1156,7 @@ function StatusMappingsCard() {
|
|||||||
{m.provider_status}
|
{m.provider_status}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDelete(m.id)}
|
onClick={() => handleUnmap(m.id)}
|
||||||
className="ml-1 text-muted-foreground hover:text-destructive transition-colors"
|
className="ml-1 text-muted-foreground hover:text-destructive transition-colors"
|
||||||
title={t("common.delete")}
|
title={t("common.delete")}
|
||||||
>
|
>
|
||||||
@@ -1166,17 +1164,36 @@ function StatusMappingsCard() {
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
{items.length === 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground italic">{t("settings.noMappings")}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Unmapped provider statuses */}
|
{/* Unmapped provider statuses (null mapped_status + brand new from providers) */}
|
||||||
{unmappedProviderStatuses.length > 0 && (
|
{(unmappedMappings.length > 0 || newProviderStatuses.length > 0) && (
|
||||||
<div className="border-t border-border/50 pt-4">
|
<div className="border-t border-border/50 pt-4">
|
||||||
<h4 className="text-sm font-medium text-foreground mb-3">{t("settings.unmappedSection")}</h4>
|
<h4 className="text-sm font-medium text-foreground mb-3">{t("settings.unmappedSection")}</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{unmappedProviderStatuses.map((ps) => (
|
{unmappedMappings.map((m) => (
|
||||||
|
<div key={m.id} className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-mono bg-muted/50 px-2 py-1 rounded-md min-w-[120px]">{m.provider_status}</span>
|
||||||
|
<Icon name="chevronRight" size="sm" />
|
||||||
|
<FormSelect
|
||||||
|
className="w-auto"
|
||||||
|
value=""
|
||||||
|
onChange={(e) => { if (e.target.value) handleAssign(m.provider_status, e.target.value); }}
|
||||||
|
>
|
||||||
|
<option value="">{t("settings.selectTargetStatus")}</option>
|
||||||
|
{allTargets.map((s) => (
|
||||||
|
<option key={s} value={s}>{statusLabel(s)}</option>
|
||||||
|
))}
|
||||||
|
</FormSelect>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{newProviderStatuses.map((ps) => (
|
||||||
<div key={ps} className="flex items-center gap-2">
|
<div key={ps} className="flex items-center gap-2">
|
||||||
<span className="text-sm font-mono bg-muted/50 px-2 py-1 rounded-md min-w-[120px]">{ps}</span>
|
<span className="text-sm font-mono bg-muted/50 px-2 py-1 rounded-md min-w-[120px]">{ps}</span>
|
||||||
<Icon name="chevronRight" size="sm" />
|
<Icon name="chevronRight" size="sm" />
|
||||||
@@ -1186,7 +1203,7 @@ function StatusMappingsCard() {
|
|||||||
onChange={(e) => { if (e.target.value) handleAssign(ps, e.target.value); }}
|
onChange={(e) => { if (e.target.value) handleAssign(ps, e.target.value); }}
|
||||||
>
|
>
|
||||||
<option value="">{t("settings.selectTargetStatus")}</option>
|
<option value="">{t("settings.selectTargetStatus")}</option>
|
||||||
{targetStatuses.map((s) => (
|
{allTargets.map((s) => (
|
||||||
<option key={s} value={s}>{statusLabel(s)}</option>
|
<option key={s} value={s}>{statusLabel(s)}</option>
|
||||||
))}
|
))}
|
||||||
</FormSelect>
|
</FormSelect>
|
||||||
|
|||||||
@@ -433,7 +433,7 @@ export async function getThumbnailStats() {
|
|||||||
export type StatusMappingDto = {
|
export type StatusMappingDto = {
|
||||||
id: string;
|
id: string;
|
||||||
provider_status: string;
|
provider_status: string;
|
||||||
mapped_status: string;
|
mapped_status: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchStatusMappings(): Promise<StatusMappingDto[]> {
|
export async function fetchStatusMappings(): Promise<StatusMappingDto[]> {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
3
infra/migrations/0042_nullable_mapped_status.sql
Normal file
3
infra/migrations/0042_nullable_mapped_status.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- Allow mapped_status to be NULL to represent "known but unmapped" provider statuses.
|
||||||
|
-- Clicking X in the UI will set mapped_status to NULL instead of deleting the row.
|
||||||
|
ALTER TABLE status_mappings ALTER COLUMN mapped_status DROP NOT NULL;
|
||||||
Reference in New Issue
Block a user