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:
@@ -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;
|
||||
|
||||
66
backend/src/repo/reaction.rs
Normal file
66
backend/src/repo/reaction.rs
Normal 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)))
|
||||
}
|
||||
Reference in New Issue
Block a user