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

@@ -11,6 +11,7 @@ pub mod history;
pub mod mangas;
pub mod page_tags;
pub mod pagination;
pub mod reactions;
pub mod tags;
use axum::Router;
@@ -31,5 +32,6 @@ pub fn routes() -> Router<AppState> {
.merge(collections::routes())
.merge(page_tags::routes())
.merge(history::routes())
.merge(reactions::routes())
.merge(admin::routes())
}

View File

@@ -0,0 +1,69 @@
//! Manga reactions (like/dislike) — a private, per-user taste signal.
//! Writes require auth; the read is scoped under `/me/` so the URL can't be
//! used to peek at another user's reactions.
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::routing::{get, put};
use axum::{Json, Router};
use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;
use crate::app::AppState;
use crate::auth::extractor::CurrentUser;
use crate::domain::reaction::{MangaReaction, Reaction};
use crate::error::{AppError, AppResult};
use crate::repo;
pub fn routes() -> Router<AppState> {
Router::new()
.route(
"/mangas/:id/reaction",
put(set_reaction).delete(clear_reaction),
)
.route("/me/reactions/:manga_id", get(get_reaction))
}
#[derive(Debug, Deserialize)]
pub struct SetReactionBody {
pub reaction: String,
}
async fn set_reaction(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(manga_id): Path<Uuid>,
Json(body): Json<SetReactionBody>,
) -> AppResult<Json<MangaReaction>> {
// Validate against the closed vocabulary here so a bad value is a clean
// 422 rather than relying on the DB CHECK to surface as a 500.
let reaction = Reaction::parse(&body.reaction).ok_or_else(|| AppError::ValidationFailed {
message: "reaction must be 'like' or 'dislike'".into(),
details: json!({ "reaction": "must be 'like' or 'dislike'" }),
})?;
// Unknown manga → 404 via the FK-violation mapping in repo::reaction.
repo::reaction::upsert(&state.db, user.id, manga_id, reaction).await?;
Ok(Json(MangaReaction {
manga_id,
reaction: Some(reaction),
}))
}
async fn clear_reaction(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(manga_id): Path<Uuid>,
) -> AppResult<StatusCode> {
repo::reaction::clear(&state.db, user.id, manga_id).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn get_reaction(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(manga_id): Path<Uuid>,
) -> AppResult<Json<MangaReaction>> {
let reaction = repo::reaction::get(&state.db, user.id, manga_id).await?;
Ok(Json(MangaReaction { manga_id, reaction }))
}

View File

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

View File

@@ -0,0 +1,40 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// A user's private taste signal on a manga. Stored as text in
/// `manga_reactions.reaction` (CHECK-constrained), so we map to/from a
/// `&str` at the repo layer rather than deriving a Postgres enum type.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Reaction {
Like,
Dislike,
}
impl Reaction {
pub fn as_str(self) -> &'static str {
match self {
Reaction::Like => "like",
Reaction::Dislike => "dislike",
}
}
/// Parse a stored/inbound value. Returns `None` for anything outside the
/// closed vocabulary (the DB CHECK guarantees stored rows are valid; this
/// also guards the inbound API body).
pub fn parse(s: &str) -> Option<Reaction> {
match s {
"like" => Some(Reaction::Like),
"dislike" => Some(Reaction::Dislike),
_ => None,
}
}
}
/// Response shape for `GET /me/reactions/:manga_id` — the current user's
/// reaction on one manga, or `null` when they haven't reacted.
#[derive(Debug, Clone, Serialize)]
pub struct MangaReaction {
pub manga_id: Uuid,
pub reaction: Option<Reaction>,
}

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)))
}