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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.105.0"
|
||||
version = "0.106.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.105.0"
|
||||
version = "0.106.0"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
15
backend/migrations/0035_manga_reactions.sql
Normal file
15
backend/migrations/0035_manga_reactions.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- Per-user like/dislike reactions on mangas — a private taste signal that
|
||||
-- powers content-based recommendations. One row per (user, manga); the
|
||||
-- `reaction` column toggles between 'like' and 'dislike', and clearing a
|
||||
-- reaction deletes the row. Reactions are never exposed publicly (no counts).
|
||||
CREATE TABLE manga_reactions (
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
manga_id uuid NOT NULL REFERENCES mangas(id) ON DELETE CASCADE,
|
||||
reaction text NOT NULL CHECK (reaction IN ('like', 'dislike')),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, manga_id)
|
||||
);
|
||||
|
||||
-- Recommendations aggregate a user's reacted mangas by tag; the PK covers
|
||||
-- per-user lookups, this covers the reverse (all reactions on a manga).
|
||||
CREATE INDEX manga_reactions_manga_idx ON manga_reactions (manga_id);
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
69
backend/src/api/reactions.rs
Normal file
69
backend/src/api/reactions.rs
Normal 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 }))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
40
backend/src/domain/reaction.rs
Normal file
40
backend/src/domain/reaction.rs
Normal 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>,
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
133
backend/tests/api_reactions.rs
Normal file
133
backend/tests/api_reactions.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn put_reaction(
|
||||
app: &axum::Router,
|
||||
cookie: &str,
|
||||
manga_id: Uuid,
|
||||
reaction: &str,
|
||||
) -> StatusCode {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(common::put_json_with_cookie(
|
||||
&format!("/api/v1/mangas/{manga_id}/reaction"),
|
||||
json!({ "reaction": reaction }),
|
||||
cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
resp.status()
|
||||
}
|
||||
|
||||
async fn get_reaction(app: &axum::Router, cookie: &str, manga_id: Uuid) -> serde_json::Value {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(common::get_with_cookie(
|
||||
&format!("/api/v1/me/reactions/{manga_id}"),
|
||||
cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
common::body_json(resp).await
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn put_creates_and_reads_back(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
|
||||
|
||||
assert_eq!(put_reaction(&h.app, &cookie, manga_id, "like").await, StatusCode::OK);
|
||||
let body = get_reaction(&h.app, &cookie, manga_id).await;
|
||||
assert_eq!(body["reaction"], "like");
|
||||
assert_eq!(body["manga_id"], manga_id.to_string());
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn put_toggles_like_to_dislike(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
|
||||
|
||||
let _ = put_reaction(&h.app, &cookie, manga_id, "like").await;
|
||||
assert_eq!(put_reaction(&h.app, &cookie, manga_id, "dislike").await, StatusCode::OK);
|
||||
assert_eq!(get_reaction(&h.app, &cookie, manga_id).await["reaction"], "dislike");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn delete_clears_the_reaction(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
|
||||
let _ = put_reaction(&h.app, &cookie, manga_id, "like").await;
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::delete_with_cookie(
|
||||
&format!("/api/v1/mangas/{manga_id}/reaction"),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
||||
assert_eq!(get_reaction(&h.app, &cookie, manga_id).await["reaction"], serde_json::Value::Null);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn get_unset_returns_null(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
|
||||
assert_eq!(get_reaction(&h.app, &cookie, manga_id).await["reaction"], serde_json::Value::Null);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn reactions_are_per_user(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, a) = common::register_user(&h.app).await;
|
||||
let (_, b) = common::register_user(&h.app).await;
|
||||
let manga_id = common::seed_manga_via_api(&h.app, &a, "Berserk").await;
|
||||
let _ = put_reaction(&h.app, &a, manga_id, "like").await;
|
||||
// B sees no reaction of their own.
|
||||
assert_eq!(get_reaction(&h.app, &b, manga_id).await["reaction"], serde_json::Value::Null);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn put_unknown_manga_is_404(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
assert_eq!(
|
||||
put_reaction(&h.app, &cookie, Uuid::new_v4(), "like").await,
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn put_invalid_reaction_is_422(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
let manga_id = common::seed_manga_via_api(&h.app, &cookie, "Berserk").await;
|
||||
assert_eq!(
|
||||
put_reaction(&h.app, &cookie, manga_id, "meh").await,
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn get_requires_authentication(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::get(&format!("/api/v1/me/reactions/{}", Uuid::new_v4())))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.105.0",
|
||||
"version": "0.106.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.105.0",
|
||||
"version": "0.106.0",
|
||||
"devDependencies": {
|
||||
"@lucide/svelte": "^1.16.0",
|
||||
"@playwright/test": "^1.48.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.105.0",
|
||||
"version": "0.106.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user