fix: make bookmark add idempotent instead of 409
Re-adding an already-bookmarked manga returned 409, which the UI surfaced as a false "Could not add bookmark" toast — collections are idempotent, bookmarks weren't. On a unique violation, fetch and return the existing bookmark, and answer 200 (vs 201 for a fresh insert). The frontend toggle already treats a successful POST as done, so the false toast disappears with no client change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -72,7 +72,7 @@ async fn create(
|
||||
}
|
||||
}
|
||||
|
||||
let bookmark = repo::bookmark::create(
|
||||
let (bookmark, created) = repo::bookmark::create(
|
||||
&state.db,
|
||||
user.id,
|
||||
input.manga_id,
|
||||
@@ -103,7 +103,13 @@ async fn create(
|
||||
}
|
||||
});
|
||||
|
||||
Ok((StatusCode::CREATED, Json(bookmark)))
|
||||
// 201 for a fresh bookmark, 200 when it already existed (idempotent add).
|
||||
let status = if created {
|
||||
StatusCode::CREATED
|
||||
} else {
|
||||
StatusCode::OK
|
||||
};
|
||||
Ok((status, Json(bookmark)))
|
||||
}
|
||||
|
||||
async fn delete_one(
|
||||
|
||||
@@ -6,13 +6,23 @@ use uuid::Uuid;
|
||||
use crate::domain::{Bookmark, BookmarkSummary};
|
||||
use crate::error::{AppError, AppResult};
|
||||
|
||||
/// Add a bookmark, idempotently. Returns the bookmark plus whether it was
|
||||
/// newly created (`true`) or already existed (`false`), so the handler can
|
||||
/// answer 201 vs 200. Re-adding an existing bookmark is a no-op success rather
|
||||
/// than a 409 — matching the idempotent collection semantics, so the UI doesn't
|
||||
/// show a false "Could not add bookmark" toast when the manga is already saved.
|
||||
///
|
||||
/// Uniqueness is per `(user_id, manga_id, chapter_id)` — enforced by the 0001
|
||||
/// constraint for chapter-level rows and the 0004 partial index for manga-level
|
||||
/// (NULL chapter) rows. `page` is not part of the key, so the existing row is
|
||||
/// returned unchanged (its page is not overwritten).
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
manga_id: Uuid,
|
||||
chapter_id: Option<Uuid>,
|
||||
page: Option<i32>,
|
||||
) -> AppResult<Bookmark> {
|
||||
) -> AppResult<(Bookmark, bool)> {
|
||||
let result = sqlx::query_as::<_, Bookmark>(
|
||||
r#"
|
||||
INSERT INTO bookmarks (user_id, manga_id, chapter_id, page)
|
||||
@@ -28,10 +38,27 @@ pub async fn create(
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(b) => Ok(b),
|
||||
Err(sqlx::Error::Database(ref db_err)) if db_err.is_unique_violation() => Err(
|
||||
AppError::Conflict("bookmark already exists for this manga/chapter".into()),
|
||||
),
|
||||
Ok(b) => Ok((b, true)),
|
||||
Err(sqlx::Error::Database(ref db_err)) if db_err.is_unique_violation() => {
|
||||
// A bookmark for this (user, manga, chapter) already exists — fetch
|
||||
// and return it. `IS NOT DISTINCT FROM` matches a NULL chapter_id
|
||||
// (manga-level bookmark) as well as a concrete one, covering both
|
||||
// uniqueness paths with a single lookup.
|
||||
let existing = sqlx::query_as::<_, Bookmark>(
|
||||
r#"
|
||||
SELECT id, user_id, manga_id, chapter_id, page, created_at
|
||||
FROM bookmarks
|
||||
WHERE user_id = $1 AND manga_id = $2
|
||||
AND chapter_id IS NOT DISTINCT FROM $3
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(manga_id)
|
||||
.bind(chapter_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok((existing, false))
|
||||
}
|
||||
Err(e) => Err(AppError::Database(e)),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user