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>
70 lines
2.2 KiB
Rust
70 lines
2.2 KiB
Rust
//! 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 }))
|
|
}
|