feat(reactions): per-user like/dislike on mangas

Add a private per-user reaction resource: a manga_reactions table (one row
per user+manga, like/dislike, cascades) and endpoints PUT/DELETE
/mangas/:id/reaction plus GET /me/reactions/:manga_id, wired on the existing
per-user seam (mirrors bookmarks/read_progress). Unknown manga → 404 via the
FK-violation mapping; an invalid reaction value → 422. Reactions are never
exposed publicly — this is a taste signal for the upcoming content-based
recommendations. Integration tests cover set/toggle/clear/read, per-user
isolation, and the 404/422/401 paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-05 16:53:59 +02:00
parent 9c4a93c058
commit 258a536254
12 changed files with 332 additions and 5 deletions

View File

@@ -13,6 +13,7 @@ pub mod manga;
pub mod page;
pub mod page_analysis;
pub mod page_tag;
pub mod reaction;
pub mod read_progress;
pub mod session;
pub mod storage_stats;

View File

@@ -0,0 +1,66 @@
//! Per-user manga reaction (like/dislike) persistence.
use sqlx::PgPool;
use uuid::Uuid;
use crate::domain::reaction::Reaction;
use crate::error::{AppError, AppResult};
/// Insert-or-overwrite the user's reaction on this manga (like ↔ dislike).
/// A foreign-key violation (unknown manga) maps to `NotFound` so the API
/// returns 404 rather than 500 — mirrors `read_progress::upsert`.
pub async fn upsert(
pool: &PgPool,
user_id: Uuid,
manga_id: Uuid,
reaction: Reaction,
) -> AppResult<()> {
sqlx::query(
r#"
INSERT INTO manga_reactions (user_id, manga_id, reaction, created_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (user_id, manga_id) DO UPDATE
SET reaction = EXCLUDED.reaction,
created_at = now()
"#,
)
.bind(user_id)
.bind(manga_id)
.bind(reaction.as_str())
.execute(pool)
.await
.map_err(|e| match e {
sqlx::Error::Database(ref db_err) if db_err.is_foreign_key_violation() => {
AppError::NotFound
}
other => AppError::Database(other),
})?;
Ok(())
}
/// Remove the user's reaction on this manga. Idempotent — clearing a
/// non-existent reaction is a no-op.
pub async fn clear(pool: &PgPool, user_id: Uuid, manga_id: Uuid) -> AppResult<()> {
sqlx::query("DELETE FROM manga_reactions WHERE user_id = $1 AND manga_id = $2")
.bind(user_id)
.bind(manga_id)
.execute(pool)
.await?;
Ok(())
}
/// The user's current reaction on this manga, or `None` if unset.
pub async fn get(
pool: &PgPool,
user_id: Uuid,
manga_id: Uuid,
) -> AppResult<Option<Reaction>> {
let row: Option<(String,)> = sqlx::query_as(
"SELECT reaction FROM manga_reactions WHERE user_id = $1 AND manga_id = $2",
)
.bind(user_id)
.bind(manga_id)
.fetch_optional(pool)
.await?;
Ok(row.and_then(|(s,)| Reaction::parse(&s)))
}