diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 37381d8..f6aaccd 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.105.0" +version = "0.106.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 5392041..593900b 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.105.0" +version = "0.106.0" edition = "2021" default-run = "mangalord" diff --git a/backend/migrations/0035_manga_reactions.sql b/backend/migrations/0035_manga_reactions.sql new file mode 100644 index 0000000..5322013 --- /dev/null +++ b/backend/migrations/0035_manga_reactions.sql @@ -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); diff --git a/backend/src/api/mod.rs b/backend/src/api/mod.rs index 0f8a6a3..e014394 100644 --- a/backend/src/api/mod.rs +++ b/backend/src/api/mod.rs @@ -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 { .merge(collections::routes()) .merge(page_tags::routes()) .merge(history::routes()) + .merge(reactions::routes()) .merge(admin::routes()) } diff --git a/backend/src/api/reactions.rs b/backend/src/api/reactions.rs new file mode 100644 index 0000000..7a7bb5b --- /dev/null +++ b/backend/src/api/reactions.rs @@ -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 { + 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, + CurrentUser(user): CurrentUser, + Path(manga_id): Path, + Json(body): Json, +) -> AppResult> { + // 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, + CurrentUser(user): CurrentUser, + Path(manga_id): Path, +) -> AppResult { + repo::reaction::clear(&state.db, user.id, manga_id).await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn get_reaction( + State(state): State, + CurrentUser(user): CurrentUser, + Path(manga_id): Path, +) -> AppResult> { + let reaction = repo::reaction::get(&state.db, user.id, manga_id).await?; + Ok(Json(MangaReaction { manga_id, reaction })) +} diff --git a/backend/src/domain/mod.rs b/backend/src/domain/mod.rs index 000c713..2c843b1 100644 --- a/backend/src/domain/mod.rs +++ b/backend/src/domain/mod.rs @@ -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; diff --git a/backend/src/domain/reaction.rs b/backend/src/domain/reaction.rs new file mode 100644 index 0000000..f500ad8 --- /dev/null +++ b/backend/src/domain/reaction.rs @@ -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 { + 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, +} diff --git a/backend/src/repo/mod.rs b/backend/src/repo/mod.rs index b41588c..49e2d14 100644 --- a/backend/src/repo/mod.rs +++ b/backend/src/repo/mod.rs @@ -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; diff --git a/backend/src/repo/reaction.rs b/backend/src/repo/reaction.rs new file mode 100644 index 0000000..717a9de --- /dev/null +++ b/backend/src/repo/reaction.rs @@ -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> { + 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))) +} diff --git a/backend/tests/api_reactions.rs b/backend/tests/api_reactions.rs new file mode 100644 index 0000000..9b98d28 --- /dev/null +++ b/backend/tests/api_reactions.rs @@ -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); +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 159c8be..e945a73 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index e7ea43e..929d8a0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.105.0", + "version": "0.106.0", "private": true, "type": "module", "scripts": {