From 93a5b4d11f999a20dbef80720882f7983c446a50 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sat, 13 Jun 2026 16:01:23 -0700 Subject: [PATCH 01/13] feat(db): add collections, read lists, and want-to-read schema Add the database foundation for three Komga-style grouping concepts: - collections + collection_series: shared, ordered groupings of series - read_lists + read_list_books: shared, ordered groupings of books across series (with a nullable summary) - want_to_read: per-user on-deck queue where each row flags exactly one series or one book, enforced by an inline CHECK constraint and two partial-unique indexes per user Junction tables carry a position column and cascade-delete with their parents; reverse relations are wired into the series, books, and users entities. The CHECK is rendered inline at table creation so it holds on SQLite as well as PostgreSQL. Covered by schema tests for ordered membership, uniqueness, cascade deletes, and the want-to-read CHECK, run on SQLite with ignored PostgreSQL variants. --- crates/codex-db/src/entities/books.rs | 27 ++ .../src/entities/collection_series.rs | 54 +++ crates/codex-db/src/entities/collections.rs | 47 +++ crates/codex-db/src/entities/mod.rs | 7 + crates/codex-db/src/entities/prelude.rs | 12 + .../codex-db/src/entities/read_list_books.rs | 54 +++ crates/codex-db/src/entities/read_lists.rs | 50 +++ crates/codex-db/src/entities/series.rs | 27 ++ crates/codex-db/src/entities/users.rs | 8 + crates/codex-db/src/entities/want_to_read.rs | 70 ++++ migration/src/lib.rs | 13 + .../m20260615_000097_create_collections.rs | 177 ++++++++ .../src/m20260615_000098_create_read_lists.rs | 176 ++++++++ .../m20260615_000099_create_want_to_read.rs | 129 ++++++ tests/db/collections.rs | 381 ++++++++++++++++++ tests/db/mod.rs | 1 + 16 files changed, 1233 insertions(+) create mode 100644 crates/codex-db/src/entities/collection_series.rs create mode 100644 crates/codex-db/src/entities/collections.rs create mode 100644 crates/codex-db/src/entities/read_list_books.rs create mode 100644 crates/codex-db/src/entities/read_lists.rs create mode 100644 crates/codex-db/src/entities/want_to_read.rs create mode 100644 migration/src/m20260615_000097_create_collections.rs create mode 100644 migration/src/m20260615_000098_create_read_lists.rs create mode 100644 migration/src/m20260615_000099_create_want_to_read.rs create mode 100644 tests/db/collections.rs diff --git a/crates/codex-db/src/entities/books.rs b/crates/codex-db/src/entities/books.rs index 776793ad..bb1f8dbe 100644 --- a/crates/codex-db/src/entities/books.rs +++ b/crates/codex-db/src/entities/books.rs @@ -69,6 +69,11 @@ pub enum Relation { BookGenres, #[sea_orm(has_many = "super::book_tags::Entity")] BookTags, + // Read list membership and per-user want-to-read flags. + #[sea_orm(has_many = "super::read_list_books::Entity")] + ReadListBooks, + #[sea_orm(has_many = "super::want_to_read::Entity")] + WantToRead, } impl Related for Entity { @@ -131,4 +136,26 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::ReadListBooks.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::WantToRead.def() + } +} + +// Many-to-many: books belong to many read lists via the read_list_books join. +impl Related for Entity { + fn to() -> RelationDef { + super::read_list_books::Relation::ReadList.def() + } + fn via() -> Option { + Some(super::read_list_books::Relation::Book.def().rev()) + } +} + impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/codex-db/src/entities/collection_series.rs b/crates/codex-db/src/entities/collection_series.rs new file mode 100644 index 00000000..2a32a07f --- /dev/null +++ b/crates/codex-db/src/entities/collection_series.rs @@ -0,0 +1,54 @@ +//! `SeaORM` Entity for collection_series junction table +//! +//! Ordered membership linking collections to series (many-to-many). + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "collection_series")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub collection_id: Uuid, + pub series_id: Uuid, + /// Honored only when the parent collection's `ordered` flag is true. + pub position: i32, + pub created_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::collections::Entity", + from = "Column::CollectionId", + to = "super::collections::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Collection, + #[sea_orm( + belongs_to = "super::series::Entity", + from = "Column::SeriesId", + to = "super::series::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Series, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Collection.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Series.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/codex-db/src/entities/collections.rs b/crates/codex-db/src/entities/collections.rs new file mode 100644 index 00000000..26c90efa --- /dev/null +++ b/crates/codex-db/src/entities/collections.rs @@ -0,0 +1,47 @@ +//! `SeaORM` Entity for collections table +//! +//! A collection is a shared, named grouping of series (Komga-style). Membership +//! and order live in the `collection_series` junction. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "collections")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + #[sea_orm(unique)] + pub name: String, + #[sea_orm(unique)] + pub normalized_name: String, + /// false => members sorted by series title; true => use `position`. + pub ordered: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::collection_series::Entity")] + CollectionSeries, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::CollectionSeries.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::collection_series::Relation::Series.def() + } + fn via() -> Option { + Some(super::collection_series::Relation::Collection.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/codex-db/src/entities/mod.rs b/crates/codex-db/src/entities/mod.rs index 3a2c78e9..23d7f176 100644 --- a/crates/codex-db/src/entities/mod.rs +++ b/crates/codex-db/src/entities/mod.rs @@ -78,3 +78,10 @@ pub mod access_group_oidc_mappings; pub mod access_group_sharing_tags; pub mod access_groups; pub mod user_access_groups; + +// Collections (series), read lists (books), and the per-user want-to-read queue +pub mod collection_series; +pub mod collections; +pub mod read_list_books; +pub mod read_lists; +pub mod want_to_read; diff --git a/crates/codex-db/src/entities/prelude.rs b/crates/codex-db/src/entities/prelude.rs index cfc02564..b801a7b1 100644 --- a/crates/codex-db/src/entities/prelude.rs +++ b/crates/codex-db/src/entities/prelude.rs @@ -75,3 +75,15 @@ pub use super::access_group_sharing_tags::Entity as AccessGroupSharingTags; pub use super::access_groups::Entity as AccessGroups; #[allow(unused_imports)] pub use super::user_access_groups::Entity as UserAccessGroups; + +// Collections, read lists, and the per-user want-to-read queue +#[allow(unused_imports)] +pub use super::collection_series::Entity as CollectionSeries; +#[allow(unused_imports)] +pub use super::collections::Entity as Collections; +#[allow(unused_imports)] +pub use super::read_list_books::Entity as ReadListBooks; +#[allow(unused_imports)] +pub use super::read_lists::Entity as ReadLists; +#[allow(unused_imports)] +pub use super::want_to_read::Entity as WantToRead; diff --git a/crates/codex-db/src/entities/read_list_books.rs b/crates/codex-db/src/entities/read_list_books.rs new file mode 100644 index 00000000..d5f7e978 --- /dev/null +++ b/crates/codex-db/src/entities/read_list_books.rs @@ -0,0 +1,54 @@ +//! `SeaORM` Entity for read_list_books junction table +//! +//! Ordered membership linking read lists to books (many-to-many). + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "read_list_books")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub read_list_id: Uuid, + pub book_id: Uuid, + /// Honored only when the parent read list's `ordered` flag is true. + pub position: i32, + pub created_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::read_lists::Entity", + from = "Column::ReadListId", + to = "super::read_lists::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + ReadList, + #[sea_orm( + belongs_to = "super::books::Entity", + from = "Column::BookId", + to = "super::books::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Book, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ReadList.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Book.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/codex-db/src/entities/read_lists.rs b/crates/codex-db/src/entities/read_lists.rs new file mode 100644 index 00000000..74a4f85f --- /dev/null +++ b/crates/codex-db/src/entities/read_lists.rs @@ -0,0 +1,50 @@ +//! `SeaORM` Entity for read_lists table +//! +//! A read list is a shared, ordered grouping of books across series (Komga-style +//! "playlist for books"). Membership and order live in the `read_list_books` +//! junction. + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "read_lists")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + #[sea_orm(unique)] + pub name: String, + #[sea_orm(unique)] + pub normalized_name: String, + #[sea_orm(column_type = "Text", nullable)] + pub summary: Option, + /// true (default) => manual reading order; false => sort members by release date. + pub ordered: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::read_list_books::Entity")] + ReadListBooks, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ReadListBooks.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::read_list_books::Relation::Book.def() + } + fn via() -> Option { + Some(super::read_list_books::Relation::ReadList.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/codex-db/src/entities/series.rs b/crates/codex-db/src/entities/series.rs index c750c4b2..d8df34fa 100644 --- a/crates/codex-db/src/entities/series.rs +++ b/crates/codex-db/src/entities/series.rs @@ -68,6 +68,11 @@ pub enum Relation { // Release ledger entries for this series. #[sea_orm(has_many = "super::release_ledger::Entity")] ReleaseLedger, + // Collection membership and per-user want-to-read flags. + #[sea_orm(has_many = "super::collection_series::Entity")] + CollectionSeries, + #[sea_orm(has_many = "super::want_to_read::Entity")] + WantToRead, } impl Related for Entity { @@ -194,4 +199,26 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::CollectionSeries.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::WantToRead.def() + } +} + +// Many-to-many: series belong to many collections via the collection_series join. +impl Related for Entity { + fn to() -> RelationDef { + super::collection_series::Relation::Collection.def() + } + fn via() -> Option { + Some(super::collection_series::Relation::Series.def().rev()) + } +} + impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/codex-db/src/entities/users.rs b/crates/codex-db/src/entities/users.rs index 97602cbd..8af30e27 100644 --- a/crates/codex-db/src/entities/users.rs +++ b/crates/codex-db/src/entities/users.rs @@ -45,6 +45,8 @@ pub enum Relation { UserPlugins, #[sea_orm(has_many = "super::user_access_groups::Entity")] UserAccessGroups, + #[sea_orm(has_many = "super::want_to_read::Entity")] + WantToRead, } impl Related for Entity { @@ -86,6 +88,12 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::WantToRead.def() + } +} + impl Related for Entity { fn to() -> RelationDef { super::user_access_groups::Relation::AccessGroup.def() diff --git a/crates/codex-db/src/entities/want_to_read.rs b/crates/codex-db/src/entities/want_to_read.rs new file mode 100644 index 00000000..7632e91c --- /dev/null +++ b/crates/codex-db/src/entities/want_to_read.rs @@ -0,0 +1,70 @@ +//! `SeaORM` Entity for want_to_read table +//! +//! Per-user, flat on-deck queue. Each row flags exactly one series OR one book +//! the user intends to read (enforced by a DB CHECK constraint). + +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "want_to_read")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub user_id: Uuid, + /// Set when this entry flags a series; mutually exclusive with `book_id`. + pub series_id: Option, + /// Set when this entry flags a book; mutually exclusive with `series_id`. + pub book_id: Option, + pub added_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::users::Entity", + from = "Column::UserId", + to = "super::users::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Users, + #[sea_orm( + belongs_to = "super::series::Entity", + from = "Column::SeriesId", + to = "super::series::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Series, + #[sea_orm( + belongs_to = "super::books::Entity", + from = "Column::BookId", + to = "super::books::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Books, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Users.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Series.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Books.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 52e2362a..b2c9a719 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -195,6 +195,13 @@ mod m20260607_000094_add_user_plugin_sync_unique_index; mod m20260608_000095_create_plugin_data; mod m20260609_000096_add_plugin_log_level; +// Collections: shared, ordered groupings of series + their join table +pub mod m20260615_000097_create_collections; +// Read lists: shared, ordered groupings of books across series + their join table +pub mod m20260615_000098_create_read_lists; +// Want to Read: per-user flat queue of series and/or books +pub mod m20260615_000099_create_want_to_read; + pub struct Migrator; #[async_trait::async_trait] @@ -361,6 +368,12 @@ impl MigratorTrait for Migrator { Box::new(m20260608_000095_create_plugin_data::Migration), // Per-plugin log-level override column on `plugins` Box::new(m20260609_000096_add_plugin_log_level::Migration), + // Collections: shared, ordered groupings of series + collection_series join + Box::new(m20260615_000097_create_collections::Migration), + // Read lists: shared, ordered groupings of books + read_list_books join + Box::new(m20260615_000098_create_read_lists::Migration), + // Want to Read: per-user flat queue of series and/or books + Box::new(m20260615_000099_create_want_to_read::Migration), ] } } diff --git a/migration/src/m20260615_000097_create_collections.rs b/migration/src/m20260615_000097_create_collections.rs new file mode 100644 index 00000000..2f2d6b86 --- /dev/null +++ b/migration/src/m20260615_000097_create_collections.rs @@ -0,0 +1,177 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Collections: shared, named groupings of series (Komga-style). + manager + .create_table( + Table::create() + .table(Collections::Table) + .if_not_exists() + .col( + ColumnDef::new(Collections::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(Collections::Name) + .string_len(255) + .not_null() + .unique_key(), + ) + .col( + ColumnDef::new(Collections::NormalizedName) + .string_len(255) + .not_null() + .unique_key(), + ) + // false => members sorted by series title; true => use position + .col( + ColumnDef::new(Collections::Ordered) + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new(Collections::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(Collections::UpdatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_collections_normalized_name") + .table(Collections::Table) + .col(Collections::NormalizedName) + .to_owned(), + ) + .await?; + + // collection_series: ordered membership (collection has many series, + // series may belong to many collections). + manager + .create_table( + Table::create() + .table(CollectionSeries::Table) + .if_not_exists() + .col( + ColumnDef::new(CollectionSeries::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(CollectionSeries::CollectionId) + .uuid() + .not_null(), + ) + .col(ColumnDef::new(CollectionSeries::SeriesId).uuid().not_null()) + // Honored only when collections.ordered = true. + .col( + ColumnDef::new(CollectionSeries::Position) + .integer() + .not_null() + .default(0), + ) + .col( + ColumnDef::new(CollectionSeries::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .foreign_key( + ForeignKey::create() + .name("fk_collection_series_collection_id") + .from(CollectionSeries::Table, CollectionSeries::CollectionId) + .to(Collections::Table, Collections::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::NoAction), + ) + .foreign_key( + ForeignKey::create() + .name("fk_collection_series_series_id") + .from(CollectionSeries::Table, CollectionSeries::SeriesId) + .to(Series::Table, Series::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::NoAction), + ) + .to_owned(), + ) + .await?; + + // A series appears at most once per collection. + manager + .create_index( + Index::create() + .name("idx_collection_series_unique") + .table(CollectionSeries::Table) + .col(CollectionSeries::CollectionId) + .col(CollectionSeries::SeriesId) + .unique() + .to_owned(), + ) + .await?; + + // Reverse lookup: which collections contain a given series. + manager + .create_index( + Index::create() + .name("idx_collection_series_series_id") + .table(CollectionSeries::Table) + .col(CollectionSeries::SeriesId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(CollectionSeries::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(Collections::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +pub enum Collections { + Table, + Id, + Name, + NormalizedName, + Ordered, + CreatedAt, + UpdatedAt, +} + +#[derive(DeriveIden)] +pub enum CollectionSeries { + Table, + Id, + CollectionId, + SeriesId, + Position, + CreatedAt, +} + +#[derive(DeriveIden)] +enum Series { + Table, + Id, +} diff --git a/migration/src/m20260615_000098_create_read_lists.rs b/migration/src/m20260615_000098_create_read_lists.rs new file mode 100644 index 00000000..85eb05ec --- /dev/null +++ b/migration/src/m20260615_000098_create_read_lists.rs @@ -0,0 +1,176 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Read lists: shared, ordered groupings of books across series + // (Komga-style "playlists for books"). + manager + .create_table( + Table::create() + .table(ReadLists::Table) + .if_not_exists() + .col( + ColumnDef::new(ReadLists::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(ReadLists::Name) + .string_len(255) + .not_null() + .unique_key(), + ) + .col( + ColumnDef::new(ReadLists::NormalizedName) + .string_len(255) + .not_null() + .unique_key(), + ) + .col(ColumnDef::new(ReadLists::Summary).text()) + // Read lists default to manual reading order (the point of a + // read list); false => members sorted by release date. + .col( + ColumnDef::new(ReadLists::Ordered) + .boolean() + .not_null() + .default(true), + ) + .col( + ColumnDef::new(ReadLists::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(ReadLists::UpdatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_read_lists_normalized_name") + .table(ReadLists::Table) + .col(ReadLists::NormalizedName) + .to_owned(), + ) + .await?; + + // read_list_books: ordered membership. + manager + .create_table( + Table::create() + .table(ReadListBooks::Table) + .if_not_exists() + .col( + ColumnDef::new(ReadListBooks::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(ReadListBooks::ReadListId).uuid().not_null()) + .col(ColumnDef::new(ReadListBooks::BookId).uuid().not_null()) + // Honored only when read_lists.ordered = true. + .col( + ColumnDef::new(ReadListBooks::Position) + .integer() + .not_null() + .default(0), + ) + .col( + ColumnDef::new(ReadListBooks::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .foreign_key( + ForeignKey::create() + .name("fk_read_list_books_read_list_id") + .from(ReadListBooks::Table, ReadListBooks::ReadListId) + .to(ReadLists::Table, ReadLists::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::NoAction), + ) + .foreign_key( + ForeignKey::create() + .name("fk_read_list_books_book_id") + .from(ReadListBooks::Table, ReadListBooks::BookId) + .to(Books::Table, Books::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::NoAction), + ) + .to_owned(), + ) + .await?; + + // A book appears at most once per read list. + manager + .create_index( + Index::create() + .name("idx_read_list_books_unique") + .table(ReadListBooks::Table) + .col(ReadListBooks::ReadListId) + .col(ReadListBooks::BookId) + .unique() + .to_owned(), + ) + .await?; + + // Reverse lookup: which read lists contain a given book. + manager + .create_index( + Index::create() + .name("idx_read_list_books_book_id") + .table(ReadListBooks::Table) + .col(ReadListBooks::BookId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(ReadListBooks::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(ReadLists::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +pub enum ReadLists { + Table, + Id, + Name, + NormalizedName, + Summary, + Ordered, + CreatedAt, + UpdatedAt, +} + +#[derive(DeriveIden)] +pub enum ReadListBooks { + Table, + Id, + ReadListId, + BookId, + Position, + CreatedAt, +} + +#[derive(DeriveIden)] +enum Books { + Table, + Id, +} diff --git a/migration/src/m20260615_000099_create_want_to_read.rs b/migration/src/m20260615_000099_create_want_to_read.rs new file mode 100644 index 00000000..48af7a77 --- /dev/null +++ b/migration/src/m20260615_000099_create_want_to_read.rs @@ -0,0 +1,129 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // want_to_read: per-user, flat on-deck queue. Each row flags exactly one + // series OR one book the user intends to read. Replaces the "open page 1 + // so it shows in Keep Reading" workaround. + manager + .create_table( + Table::create() + .table(WantToRead::Table) + .if_not_exists() + .col( + ColumnDef::new(WantToRead::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(WantToRead::UserId).uuid().not_null()) + .col(ColumnDef::new(WantToRead::SeriesId).uuid()) + .col(ColumnDef::new(WantToRead::BookId).uuid()) + .col( + ColumnDef::new(WantToRead::AddedAt) + .timestamp_with_time_zone() + .not_null(), + ) + // Exactly one of series_id / book_id must be set. Rendered + // inline at CREATE TABLE so it holds on SQLite too (SQLite + // cannot ALTER TABLE ADD CONSTRAINT CHECK). + .check(Expr::cust( + "(series_id IS NOT NULL) <> (book_id IS NOT NULL)", + )) + .foreign_key( + ForeignKey::create() + .name("fk_want_to_read_user_id") + .from(WantToRead::Table, WantToRead::UserId) + .to(Users::Table, Users::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::NoAction), + ) + .foreign_key( + ForeignKey::create() + .name("fk_want_to_read_series_id") + .from(WantToRead::Table, WantToRead::SeriesId) + .to(Series::Table, Series::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::NoAction), + ) + .foreign_key( + ForeignKey::create() + .name("fk_want_to_read_book_id") + .from(WantToRead::Table, WantToRead::BookId) + .to(Books::Table, Books::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::NoAction), + ) + .to_owned(), + ) + .await?; + + // Can't flag the same series twice for one user. NULLs are distinct in + // unique indexes (both SQLite and PostgreSQL), so book-only rows (with + // series_id NULL) don't collide. + manager + .create_index( + Index::create() + .name("idx_want_to_read_user_series_unique") + .table(WantToRead::Table) + .col(WantToRead::UserId) + .col(WantToRead::SeriesId) + .unique() + .to_owned(), + ) + .await?; + + // Can't flag the same book twice for one user. + manager + .create_index( + Index::create() + .name("idx_want_to_read_user_book_unique") + .table(WantToRead::Table) + .col(WantToRead::UserId) + .col(WantToRead::BookId) + .unique() + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(WantToRead::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +pub enum WantToRead { + Table, + Id, + UserId, + SeriesId, + BookId, + AddedAt, +} + +#[derive(DeriveIden)] +enum Users { + Table, + Id, +} + +#[derive(DeriveIden)] +enum Series { + Table, + Id, +} + +#[derive(DeriveIden)] +enum Books { + Table, + Id, +} diff --git a/tests/db/collections.rs b/tests/db/collections.rs new file mode 100644 index 00000000..62dfef75 --- /dev/null +++ b/tests/db/collections.rs @@ -0,0 +1,381 @@ +#[path = "../common/mod.rs"] +mod common; + +// Schema/entity tests for collections, read lists, and the per-user +// want-to-read queue (Phase 1 foundation). Exercises round-trips, ordered +// membership, uniqueness, cascade deletes, and the want_to_read CHECK +// constraint on both SQLite and (ignored) PostgreSQL. + +use chrono::Utc; +use codex::db::entities::{ + collection_series, collections, read_list_books, read_lists, want_to_read, +}; +use codex::db::repositories::UserRepository; +use common::*; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, + QueryOrder, Set, +}; +use uuid::Uuid; + +async fn persist_user(db: &DatabaseConnection, username: &str) -> users::Model { + let model = create_test_user(username, &format!("{username}@test.test"), "hash", false); + UserRepository::create(db, &model).await.unwrap() +} + +// ============================================================================ +// Collections +// ============================================================================ + +/// Shared assertions: collection holds ordered series membership, membership is +/// unique per (collection, series), and cascades when either side is deleted. +async fn exercise_collections(db: &DatabaseConnection) { + let lib = create_test_library(db, &format!("Lib-{}", Uuid::new_v4()), &uniq_path()).await; + let s1 = create_test_series(db, &lib, "Series A").await; + let s2 = create_test_series(db, &lib, "Series B").await; + + let coll = collections::ActiveModel { + id: Set(Uuid::new_v4()), + name: Set(format!("Batman-{}", Uuid::new_v4())), + normalized_name: Set(format!("batman-{}", Uuid::new_v4())), + ordered: Set(true), + created_at: Set(Utc::now()), + updated_at: Set(Utc::now()), + } + .insert(db) + .await + .unwrap(); + + // s2 first (position 0), s1 second (position 1) — deliberately reverse of + // insertion so the order test verifies position, not insertion order. + for (pos, s) in [(0, &s2), (1, &s1)] { + collection_series::ActiveModel { + id: Set(Uuid::new_v4()), + collection_id: Set(coll.id), + series_id: Set(s.id), + position: Set(pos), + created_at: Set(Utc::now()), + } + .insert(db) + .await + .unwrap(); + } + + // Members come back ordered by position. + let members = collection_series::Entity::find() + .filter(collection_series::Column::CollectionId.eq(coll.id)) + .order_by_asc(collection_series::Column::Position) + .all(db) + .await + .unwrap(); + assert_eq!(members.len(), 2); + assert_eq!(members[0].series_id, s2.id); + assert_eq!(members[1].series_id, s1.id); + + // A series can't be added to the same collection twice. + let dup = collection_series::ActiveModel { + id: Set(Uuid::new_v4()), + collection_id: Set(coll.id), + series_id: Set(s1.id), + position: Set(2), + created_at: Set(Utc::now()), + } + .insert(db) + .await; + assert!( + dup.is_err(), + "duplicate (collection, series) must be rejected" + ); + + // Deleting a series cascades its membership row away. + series::Entity::delete_by_id(s1.id).exec(db).await.unwrap(); + let after_series_delete = collection_series::Entity::find() + .filter(collection_series::Column::SeriesId.eq(s1.id)) + .count(db) + .await + .unwrap(); + assert_eq!( + after_series_delete, 0, + "series delete should cascade membership" + ); + + // Deleting the collection cascades the remaining membership rows. + collections::Entity::delete_by_id(coll.id) + .exec(db) + .await + .unwrap(); + let after_coll_delete = collection_series::Entity::find() + .filter(collection_series::Column::CollectionId.eq(coll.id)) + .count(db) + .await + .unwrap(); + assert_eq!( + after_coll_delete, 0, + "collection delete should cascade membership" + ); +} + +#[tokio::test] +async fn test_collections_sqlite() { + let (db, _t) = setup_test_db().await; + exercise_collections(&db).await; +} + +#[tokio::test] +#[ignore] // Requires PostgreSQL test database +async fn test_collections_postgres() { + let Some(db) = setup_test_db_postgres().await else { + eprintln!("PostgreSQL test database not available, skipping"); + return; + }; + exercise_collections(&db).await; +} + +// ============================================================================ +// Read lists +// ============================================================================ + +async fn exercise_read_lists(db: &DatabaseConnection) { + let lib = create_test_library(db, &format!("Lib-{}", Uuid::new_v4()), &uniq_path()).await; + let series = create_test_series(db, &lib, "Series A").await; + let b1 = create_test_book_with_hash(db, &lib, &series, "B1", &uniq_path(), &rand_hash()).await; + let b2 = create_test_book_with_hash(db, &lib, &series, "B2", &uniq_path(), &rand_hash()).await; + + let rl = read_lists::ActiveModel { + id: Set(Uuid::new_v4()), + name: Set(format!("Wolverine-{}", Uuid::new_v4())), + normalized_name: Set(format!("wolverine-{}", Uuid::new_v4())), + summary: Set(Some("Every book where Wolverine appears".to_string())), + ordered: Set(true), + created_at: Set(Utc::now()), + updated_at: Set(Utc::now()), + } + .insert(db) + .await + .unwrap(); + assert_eq!( + rl.summary.as_deref(), + Some("Every book where Wolverine appears") + ); + + for (pos, b) in [(0, &b2), (1, &b1)] { + read_list_books::ActiveModel { + id: Set(Uuid::new_v4()), + read_list_id: Set(rl.id), + book_id: Set(b.id), + position: Set(pos), + created_at: Set(Utc::now()), + } + .insert(db) + .await + .unwrap(); + } + + let members = read_list_books::Entity::find() + .filter(read_list_books::Column::ReadListId.eq(rl.id)) + .order_by_asc(read_list_books::Column::Position) + .all(db) + .await + .unwrap(); + assert_eq!(members.len(), 2); + assert_eq!(members[0].book_id, b2.id); + assert_eq!(members[1].book_id, b1.id); + + // A book can't be added to the same read list twice. + let dup = read_list_books::ActiveModel { + id: Set(Uuid::new_v4()), + read_list_id: Set(rl.id), + book_id: Set(b1.id), + position: Set(2), + created_at: Set(Utc::now()), + } + .insert(db) + .await; + assert!(dup.is_err(), "duplicate (read_list, book) must be rejected"); + + // Deleting a book cascades its membership row. + books::Entity::delete_by_id(b1.id).exec(db).await.unwrap(); + let after_book_delete = read_list_books::Entity::find() + .filter(read_list_books::Column::BookId.eq(b1.id)) + .count(db) + .await + .unwrap(); + assert_eq!( + after_book_delete, 0, + "book delete should cascade membership" + ); + + // Deleting the read list cascades remaining membership. + read_lists::Entity::delete_by_id(rl.id) + .exec(db) + .await + .unwrap(); + let after_rl_delete = read_list_books::Entity::find() + .filter(read_list_books::Column::ReadListId.eq(rl.id)) + .count(db) + .await + .unwrap(); + assert_eq!( + after_rl_delete, 0, + "read list delete should cascade membership" + ); +} + +#[tokio::test] +async fn test_read_lists_sqlite() { + let (db, _t) = setup_test_db().await; + exercise_read_lists(&db).await; +} + +#[tokio::test] +#[ignore] // Requires PostgreSQL test database +async fn test_read_lists_postgres() { + let Some(db) = setup_test_db_postgres().await else { + eprintln!("PostgreSQL test database not available, skipping"); + return; + }; + exercise_read_lists(&db).await; +} + +// ============================================================================ +// Want to Read (per-user queue + CHECK constraint + partial uniqueness) +// ============================================================================ + +async fn exercise_want_to_read(db: &DatabaseConnection) { + let user = persist_user(db, &format!("wtr-{}", Uuid::new_v4())).await; + let lib = create_test_library(db, &format!("Lib-{}", Uuid::new_v4()), &uniq_path()).await; + let series = create_test_series(db, &lib, "Series A").await; + let book = create_test_book_with_hash(db, &lib, &series, "B", &uniq_path(), &rand_hash()).await; + let book2 = + create_test_book_with_hash(db, &lib, &series, "B2", &uniq_path(), &rand_hash()).await; + + // A series-only entry and a book-only entry both insert cleanly. + want_to_read::ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(user.id), + series_id: Set(Some(series.id)), + book_id: Set(None), + added_at: Set(Utc::now()), + } + .insert(db) + .await + .unwrap(); + + want_to_read::ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(user.id), + series_id: Set(None), + book_id: Set(Some(book.id)), + added_at: Set(Utc::now()), + } + .insert(db) + .await + .unwrap(); + + let queue = want_to_read::Entity::find() + .filter(want_to_read::Column::UserId.eq(user.id)) + .all(db) + .await + .unwrap(); + assert_eq!( + queue.len(), + 2, + "both a series and a book entry should persist" + ); + + // CHECK: neither side set is rejected. + let both_null = want_to_read::ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(user.id), + series_id: Set(None), + book_id: Set(None), + added_at: Set(Utc::now()), + } + .insert(db) + .await; + assert!( + both_null.is_err(), + "both-null must violate the CHECK constraint" + ); + + // CHECK: both sides set is rejected. + let both_set = want_to_read::ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(user.id), + series_id: Set(Some(series.id)), + book_id: Set(Some(book2.id)), + added_at: Set(Utc::now()), + } + .insert(db) + .await; + assert!( + both_set.is_err(), + "both-set must violate the CHECK constraint" + ); + + // Same series can't be flagged twice for the same user. + let dup_series = want_to_read::ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(user.id), + series_id: Set(Some(series.id)), + book_id: Set(None), + added_at: Set(Utc::now()), + } + .insert(db) + .await; + assert!( + dup_series.is_err(), + "duplicate (user, series) must be rejected" + ); + + // A second, distinct book-only entry coexists with the first even though + // both have series_id NULL — NULLs are distinct in the unique index. + want_to_read::ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(user.id), + series_id: Set(None), + book_id: Set(Some(book2.id)), + added_at: Set(Utc::now()), + } + .insert(db) + .await + .expect("multiple book-only rows (series_id NULL) for one user must be allowed"); + + // Deleting the user cascades the whole queue away. + users::Entity::delete_by_id(user.id).exec(db).await.unwrap(); + let after_user_delete = want_to_read::Entity::find() + .filter(want_to_read::Column::UserId.eq(user.id)) + .count(db) + .await + .unwrap(); + assert_eq!( + after_user_delete, 0, + "user delete should cascade the want-to-read queue" + ); +} + +#[tokio::test] +async fn test_want_to_read_sqlite() { + let (db, _t) = setup_test_db().await; + exercise_want_to_read(&db).await; +} + +#[tokio::test] +#[ignore] // Requires PostgreSQL test database +async fn test_want_to_read_postgres() { + let Some(db) = setup_test_db_postgres().await else { + eprintln!("PostgreSQL test database not available, skipping"); + return; + }; + exercise_want_to_read(&db).await; +} + +// Distinct, throwaway paths/hashes so parallel tests (and the shared PostgreSQL +// database) never collide on unique constraints. +fn uniq_path() -> String { + format!("/test/{}", Uuid::new_v4()) +} + +fn rand_hash() -> String { + Uuid::new_v4().simple().to_string() +} diff --git a/tests/db/mod.rs b/tests/db/mod.rs index 6d2d827d..77c649ff 100644 --- a/tests/db/mod.rs +++ b/tests/db/mod.rs @@ -4,6 +4,7 @@ mod auth; mod book_duplicates; +mod collections; mod migrations; mod postgres; mod refresh_token_repository; From 64ec80cce0537adee015919f76a3c3de51afbf56 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sat, 13 Jun 2026 16:32:31 -0700 Subject: [PATCH 02/13] feat(permissions,db): add collection/read-list permissions and repositories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce six RBAC permissions — collections:read/write/delete and readlists:read/write/delete. Read is granted to the Reader role (so all roles can browse), while write/delete go to Maintainer (inherited by Admin), matching how maintainers already manage series and books. Add repositories backing the new tables: - CollectionRepository: CRUD, idempotent membership add, reorder, and visibility-filtered ordered member fetch plus counts and a collections-for-series lookup - ReadListRepository: the same shape for books, with a nullable summary and series-based book visibility filtering - WantToReadRepository: per-user idempotent add/remove for series and books, queue listing by added-at, and batch in-queue lookups for DTO enrichment Member fetches return stored position order; the unordered computed sort is left to the API layer. Covered by unit tests and updated permission role-count assertions. --- .../codex-db/src/repositories/collection.rs | 432 ++++++++++++++++++ crates/codex-db/src/repositories/mod.rs | 6 + crates/codex-db/src/repositories/read_list.rs | 429 +++++++++++++++++ .../codex-db/src/repositories/want_to_read.rs | 329 +++++++++++++ crates/codex-models/src/permissions.rs | 47 +- 5 files changed, 1240 insertions(+), 3 deletions(-) create mode 100644 crates/codex-db/src/repositories/collection.rs create mode 100644 crates/codex-db/src/repositories/read_list.rs create mode 100644 crates/codex-db/src/repositories/want_to_read.rs diff --git a/crates/codex-db/src/repositories/collection.rs b/crates/codex-db/src/repositories/collection.rs new file mode 100644 index 00000000..e3e63e5d --- /dev/null +++ b/crates/codex-db/src/repositories/collection.rs @@ -0,0 +1,432 @@ +//! Repository for collections and the collection_series junction. +//! +//! Collections are shared, named groupings of series. Membership order is held +//! by the `position` column on the junction; whether it is honored (vs. an +//! alphabetical fallback) is decided by the caller based on the collection's +//! `ordered` flag. + +#![allow(dead_code)] + +use std::collections::HashMap; + +use anyhow::Result; +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, IntoActiveModel, + PaginatorTrait, QueryFilter, QueryOrder, Set, +}; +use uuid::Uuid; + +use crate::entities::{ + collection_series, collection_series::Entity as CollectionSeries, collections, + collections::Entity as Collections, series, series::Entity as Series, +}; +use crate::repositories::visibility::{SeriesVisibility, visibility_predicate}; + +/// Repository for collection operations. +pub struct CollectionRepository; + +impl CollectionRepository { + /// Get a collection by ID. + pub async fn get_by_id( + db: &DatabaseConnection, + id: Uuid, + ) -> Result> { + Ok(Collections::find_by_id(id).one(db).await?) + } + + /// Get a collection by (case-insensitive) name. + pub async fn get_by_name( + db: &DatabaseConnection, + name: &str, + ) -> Result> { + let normalized = name.trim().to_lowercase(); + Ok(Collections::find() + .filter(collections::Column::NormalizedName.eq(normalized)) + .one(db) + .await?) + } + + /// List all collections sorted by name. + pub async fn list_all(db: &DatabaseConnection) -> Result> { + Ok(Collections::find() + .order_by_asc(collections::Column::Name) + .all(db) + .await?) + } + + /// Total number of collections. + pub async fn count(db: &DatabaseConnection) -> Result { + Ok(Collections::find().count(db).await?) + } + + /// Create a new collection. Fails if the (normalized) name already exists. + pub async fn create( + db: &DatabaseConnection, + name: &str, + ordered: bool, + ) -> Result { + let now = Utc::now(); + let model = collections::ActiveModel { + id: Set(Uuid::new_v4()), + name: Set(name.trim().to_string()), + normalized_name: Set(name.trim().to_lowercase()), + ordered: Set(ordered), + created_at: Set(now), + updated_at: Set(now), + }; + Ok(model.insert(db).await?) + } + + /// Update a collection's name and/or ordered flag. Returns `None` if the + /// collection does not exist. + pub async fn update( + db: &DatabaseConnection, + id: Uuid, + name: Option<&str>, + ordered: Option, + ) -> Result> { + let Some(existing) = Collections::find_by_id(id).one(db).await? else { + return Ok(None); + }; + let mut active = existing.into_active_model(); + if let Some(name) = name { + active.name = Set(name.trim().to_string()); + active.normalized_name = Set(name.trim().to_lowercase()); + } + if let Some(ordered) = ordered { + active.ordered = Set(ordered); + } + active.updated_at = Set(Utc::now()); + Ok(Some(active.update(db).await?)) + } + + /// Delete a collection (cascades its membership rows). Returns whether a row + /// was removed. + pub async fn delete(db: &DatabaseConnection, id: Uuid) -> Result { + let result = Collections::delete_by_id(id).exec(db).await?; + Ok(result.rows_affected > 0) + } + + /// Add a series to a collection at the end of the order. Idempotent: if the + /// series is already a member, returns the existing link unchanged. + pub async fn add_series( + db: &DatabaseConnection, + collection_id: Uuid, + series_id: Uuid, + ) -> Result { + if let Some(existing) = CollectionSeries::find() + .filter(collection_series::Column::CollectionId.eq(collection_id)) + .filter(collection_series::Column::SeriesId.eq(series_id)) + .one(db) + .await? + { + return Ok(existing); + } + + let position = Self::next_position(db, collection_id).await?; + let link = collection_series::ActiveModel { + id: Set(Uuid::new_v4()), + collection_id: Set(collection_id), + series_id: Set(series_id), + position: Set(position), + created_at: Set(Utc::now()), + }; + Ok(link.insert(db).await?) + } + + /// Remove a series from a collection. Returns whether a row was removed. + pub async fn remove_series( + db: &DatabaseConnection, + collection_id: Uuid, + series_id: Uuid, + ) -> Result { + let result = CollectionSeries::delete_many() + .filter(collection_series::Column::CollectionId.eq(collection_id)) + .filter(collection_series::Column::SeriesId.eq(series_id)) + .exec(db) + .await?; + Ok(result.rows_affected > 0) + } + + /// Set explicit positions for the given series in the order provided. Series + /// not currently members are skipped. + pub async fn reorder( + db: &DatabaseConnection, + collection_id: Uuid, + ordered_series_ids: &[Uuid], + ) -> Result<()> { + for (idx, series_id) in ordered_series_ids.iter().enumerate() { + if let Some(link) = CollectionSeries::find() + .filter(collection_series::Column::CollectionId.eq(collection_id)) + .filter(collection_series::Column::SeriesId.eq(*series_id)) + .one(db) + .await? + { + let mut active = link.into_active_model(); + active.position = Set(idx as i32); + active.update(db).await?; + } + } + Ok(()) + } + + /// Get the member series of a collection in stored order (by position, then + /// insertion time), filtered by the caller's visibility. + pub async fn get_series( + db: &DatabaseConnection, + collection_id: Uuid, + vis: Option<&SeriesVisibility>, + ) -> Result> { + if matches!(vis, Some(v) if v.is_empty_whitelist()) { + return Ok(vec![]); + } + + let mut query = CollectionSeries::find() + .filter(collection_series::Column::CollectionId.eq(collection_id)) + .order_by_asc(collection_series::Column::Position) + .order_by_asc(collection_series::Column::CreatedAt); + if let Some(vis) = vis + && let Some(expr) = visibility_predicate(collection_series::Column::SeriesId, vis) + { + query = query.filter(expr); + } + + let ordered_ids: Vec = query + .all(db) + .await? + .into_iter() + .map(|l| l.series_id) + .collect(); + if ordered_ids.is_empty() { + return Ok(vec![]); + } + + let series_models = Series::find() + .filter(series::Column::Id.is_in(ordered_ids.clone())) + .all(db) + .await?; + let by_id: HashMap = + series_models.into_iter().map(|s| (s.id, s)).collect(); + + Ok(ordered_ids + .iter() + .filter_map(|id| by_id.get(id).cloned()) + .collect()) + } + + /// Count the visible member series of a collection. + pub async fn count_series( + db: &DatabaseConnection, + collection_id: Uuid, + vis: Option<&SeriesVisibility>, + ) -> Result { + if matches!(vis, Some(v) if v.is_empty_whitelist()) { + return Ok(0); + } + let mut query = CollectionSeries::find() + .filter(collection_series::Column::CollectionId.eq(collection_id)); + if let Some(vis) = vis + && let Some(expr) = visibility_predicate(collection_series::Column::SeriesId, vis) + { + query = query.filter(expr); + } + Ok(query.count(db).await?) + } + + /// Get the collections that contain a given series, sorted by name. + pub async fn get_collections_for_series( + db: &DatabaseConnection, + series_id: Uuid, + ) -> Result> { + let collection_ids: Vec = CollectionSeries::find() + .filter(collection_series::Column::SeriesId.eq(series_id)) + .all(db) + .await? + .into_iter() + .map(|l| l.collection_id) + .collect(); + if collection_ids.is_empty() { + return Ok(vec![]); + } + Ok(Collections::find() + .filter(collections::Column::Id.is_in(collection_ids)) + .order_by_asc(collections::Column::Name) + .all(db) + .await?) + } + + /// Next position value for a new member (max existing + 1, or 0 when empty). + async fn next_position(db: &DatabaseConnection, collection_id: Uuid) -> Result { + let positions: Vec = CollectionSeries::find() + .filter(collection_series::Column::CollectionId.eq(collection_id)) + .all(db) + .await? + .into_iter() + .map(|l| l.position) + .collect(); + Ok(positions.into_iter().max().map(|m| m + 1).unwrap_or(0)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ScanningStrategy; + use crate::repositories::{LibraryRepository, SeriesRepository}; + use crate::test_helpers::create_test_db; + + async fn lib_and_series(db: &DatabaseConnection) -> (Uuid, Vec) { + let library = LibraryRepository::create(db, "Lib", "/lib", ScanningStrategy::Default) + .await + .unwrap(); + let mut series = Vec::new(); + for name in ["Alpha", "Bravo", "Charlie"] { + series.push( + SeriesRepository::create(db, library.id, name, None) + .await + .unwrap(), + ); + } + (library.id, series) + } + + #[tokio::test] + async fn test_create_update_delete() { + let (db, _t) = create_test_db().await; + let conn = db.sea_orm_connection(); + + let coll = CollectionRepository::create(conn, " Batman ", false) + .await + .unwrap(); + assert_eq!(coll.name, "Batman"); + assert_eq!(coll.normalized_name, "batman"); + assert!(!coll.ordered); + + let found = CollectionRepository::get_by_name(conn, "BATMAN") + .await + .unwrap(); + assert_eq!(found.unwrap().id, coll.id); + + let updated = CollectionRepository::update(conn, coll.id, Some("Dark Knight"), Some(true)) + .await + .unwrap() + .unwrap(); + assert_eq!(updated.name, "Dark Knight"); + assert!(updated.ordered); + + assert!(CollectionRepository::delete(conn, coll.id).await.unwrap()); + assert!( + CollectionRepository::get_by_id(conn, coll.id) + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn test_membership_add_dedupe_and_order() { + let (db, _t) = create_test_db().await; + let conn = db.sea_orm_connection(); + let (_lib, series) = lib_and_series(conn).await; + + let coll = CollectionRepository::create(conn, "Coll", true) + .await + .unwrap(); + + // Add in a deliberate order. + for s in &series { + CollectionRepository::add_series(conn, coll.id, s.id) + .await + .unwrap(); + } + // Re-adding is idempotent (no duplicate, same row). + let again = CollectionRepository::add_series(conn, coll.id, series[0].id) + .await + .unwrap(); + assert_eq!(again.position, 0); + + let members = CollectionRepository::get_series(conn, coll.id, None) + .await + .unwrap(); + assert_eq!(members.len(), 3); + assert_eq!(members[0].id, series[0].id); + assert_eq!(members[2].id, series[2].id); + + // Reverse the order and re-read. + let reversed: Vec = series.iter().rev().map(|s| s.id).collect(); + CollectionRepository::reorder(conn, coll.id, &reversed) + .await + .unwrap(); + let members = CollectionRepository::get_series(conn, coll.id, None) + .await + .unwrap(); + assert_eq!(members[0].id, series[2].id); + assert_eq!(members[2].id, series[0].id); + + // Remove one. + assert!( + CollectionRepository::remove_series(conn, coll.id, series[1].id) + .await + .unwrap() + ); + assert_eq!( + CollectionRepository::count_series(conn, coll.id, None) + .await + .unwrap(), + 2 + ); + } + + #[tokio::test] + async fn test_visibility_filtering_and_containers() { + let (db, _t) = create_test_db().await; + let conn = db.sea_orm_connection(); + let (_lib, series) = lib_and_series(conn).await; + + let coll = CollectionRepository::create(conn, "Coll", false) + .await + .unwrap(); + for s in &series { + CollectionRepository::add_series(conn, coll.id, s.id) + .await + .unwrap(); + } + + // Exclude the middle series for this viewer. + let vis = SeriesVisibility { + excluded_series_ids: vec![series[1].id], + allowed_series_ids: None, + }; + let visible = CollectionRepository::get_series(conn, coll.id, Some(&vis)) + .await + .unwrap(); + assert_eq!(visible.len(), 2); + assert!(visible.iter().all(|s| s.id != series[1].id)); + assert_eq!( + CollectionRepository::count_series(conn, coll.id, Some(&vis)) + .await + .unwrap(), + 2 + ); + + // Empty whitelist => nothing visible. + let empty = SeriesVisibility { + excluded_series_ids: vec![], + allowed_series_ids: Some(vec![]), + }; + assert!( + CollectionRepository::get_series(conn, coll.id, Some(&empty)) + .await + .unwrap() + .is_empty() + ); + + // Containers-for-series lookup. + let containers = CollectionRepository::get_collections_for_series(conn, series[0].id) + .await + .unwrap(); + assert_eq!(containers.len(), 1); + assert_eq!(containers[0].id, coll.id); + } +} diff --git a/crates/codex-db/src/repositories/mod.rs b/crates/codex-db/src/repositories/mod.rs index 1b7522ac..ffd5a7e2 100644 --- a/crates/codex-db/src/repositories/mod.rs +++ b/crates/codex-db/src/repositories/mod.rs @@ -5,6 +5,7 @@ pub mod book_covers; pub mod book_duplicates; pub mod book_external_id; pub mod book_external_links; +pub mod collection; pub mod email_verification_token; pub mod external_link; pub mod external_rating; @@ -17,6 +18,7 @@ pub mod metrics; pub mod page; pub mod plugin_failures; pub mod plugins; +pub mod read_list; pub mod read_progress; pub mod refresh_token; pub mod release_ledger; @@ -37,6 +39,7 @@ pub mod task_metrics; pub mod user; pub mod user_preferences; pub mod user_series_rating; +pub mod want_to_read; // Sharing tags for content access control pub mod sharing_tag; @@ -68,6 +71,7 @@ pub use book_covers::BookCoversRepository; pub use book_duplicates::BookDuplicatesRepository; pub use book_external_id::BookExternalIdRepository; pub use book_external_links::BookExternalLinkRepository; +pub use collection::CollectionRepository; pub use email_verification_token::EmailVerificationTokenRepository; pub use external_link::ExternalLinkRepository; pub use external_rating::ExternalRatingRepository; @@ -80,6 +84,7 @@ pub use metrics::MetricsRepository; pub use page::PageRepository; pub use plugin_failures::{FailureContext, PluginFailuresRepository}; pub use plugins::PluginsRepository; +pub use read_list::ReadListRepository; pub use read_progress::ReadProgressRepository; #[allow(unused_imports)] pub use refresh_token::{NewRefreshToken, RefreshTokenRepository}; @@ -107,6 +112,7 @@ pub use task::TaskRepository; pub use user::{UserListFilter, UserRepository}; pub use user_preferences::UserPreferencesRepository; pub use user_series_rating::UserSeriesRatingRepository; +pub use want_to_read::WantToReadRepository; // Sharing tags pub use sharing_tag::SharingTagRepository; diff --git a/crates/codex-db/src/repositories/read_list.rs b/crates/codex-db/src/repositories/read_list.rs new file mode 100644 index 00000000..f6af30d5 --- /dev/null +++ b/crates/codex-db/src/repositories/read_list.rs @@ -0,0 +1,429 @@ +//! Repository for read lists and the read_list_books junction. +//! +//! Read lists are shared, ordered groupings of books across series. Membership +//! order is held by the `position` column on the junction; whether it is honored +//! (vs. a release-date fallback) is decided by the caller based on the read +//! list's `ordered` flag. + +#![allow(dead_code)] + +use std::collections::HashMap; + +use anyhow::Result; +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, IntoActiveModel, + PaginatorTrait, QueryFilter, QueryOrder, Set, +}; +use uuid::Uuid; + +use crate::entities::{ + books, books::Entity as Books, read_list_books, read_list_books::Entity as ReadListBooks, + read_lists, read_lists::Entity as ReadLists, +}; +use crate::repositories::visibility::{SeriesVisibility, apply_book_visibility}; + +/// Repository for read list operations. +pub struct ReadListRepository; + +impl ReadListRepository { + /// Get a read list by ID. + pub async fn get_by_id(db: &DatabaseConnection, id: Uuid) -> Result> { + Ok(ReadLists::find_by_id(id).one(db).await?) + } + + /// Get a read list by (case-insensitive) name. + pub async fn get_by_name( + db: &DatabaseConnection, + name: &str, + ) -> Result> { + let normalized = name.trim().to_lowercase(); + Ok(ReadLists::find() + .filter(read_lists::Column::NormalizedName.eq(normalized)) + .one(db) + .await?) + } + + /// List all read lists sorted by name. + pub async fn list_all(db: &DatabaseConnection) -> Result> { + Ok(ReadLists::find() + .order_by_asc(read_lists::Column::Name) + .all(db) + .await?) + } + + /// Total number of read lists. + pub async fn count(db: &DatabaseConnection) -> Result { + Ok(ReadLists::find().count(db).await?) + } + + /// Create a new read list. Fails if the (normalized) name already exists. + pub async fn create( + db: &DatabaseConnection, + name: &str, + summary: Option<&str>, + ordered: bool, + ) -> Result { + let now = Utc::now(); + let model = read_lists::ActiveModel { + id: Set(Uuid::new_v4()), + name: Set(name.trim().to_string()), + normalized_name: Set(name.trim().to_lowercase()), + summary: Set(summary.map(|s| s.to_string())), + ordered: Set(ordered), + created_at: Set(now), + updated_at: Set(now), + }; + Ok(model.insert(db).await?) + } + + /// Update a read list's name, summary, and/or ordered flag. Returns `None` + /// if the read list does not exist. `summary = Some(None)` clears it. + pub async fn update( + db: &DatabaseConnection, + id: Uuid, + name: Option<&str>, + summary: Option>, + ordered: Option, + ) -> Result> { + let Some(existing) = ReadLists::find_by_id(id).one(db).await? else { + return Ok(None); + }; + let mut active = existing.into_active_model(); + if let Some(name) = name { + active.name = Set(name.trim().to_string()); + active.normalized_name = Set(name.trim().to_lowercase()); + } + if let Some(summary) = summary { + active.summary = Set(summary.map(|s| s.to_string())); + } + if let Some(ordered) = ordered { + active.ordered = Set(ordered); + } + active.updated_at = Set(Utc::now()); + Ok(Some(active.update(db).await?)) + } + + /// Delete a read list (cascades its membership rows). Returns whether a row + /// was removed. + pub async fn delete(db: &DatabaseConnection, id: Uuid) -> Result { + let result = ReadLists::delete_by_id(id).exec(db).await?; + Ok(result.rows_affected > 0) + } + + /// Add a book to a read list at the end of the order. Idempotent. + pub async fn add_book( + db: &DatabaseConnection, + read_list_id: Uuid, + book_id: Uuid, + ) -> Result { + if let Some(existing) = ReadListBooks::find() + .filter(read_list_books::Column::ReadListId.eq(read_list_id)) + .filter(read_list_books::Column::BookId.eq(book_id)) + .one(db) + .await? + { + return Ok(existing); + } + + let position = Self::next_position(db, read_list_id).await?; + let link = read_list_books::ActiveModel { + id: Set(Uuid::new_v4()), + read_list_id: Set(read_list_id), + book_id: Set(book_id), + position: Set(position), + created_at: Set(Utc::now()), + }; + Ok(link.insert(db).await?) + } + + /// Remove a book from a read list. Returns whether a row was removed. + pub async fn remove_book( + db: &DatabaseConnection, + read_list_id: Uuid, + book_id: Uuid, + ) -> Result { + let result = ReadListBooks::delete_many() + .filter(read_list_books::Column::ReadListId.eq(read_list_id)) + .filter(read_list_books::Column::BookId.eq(book_id)) + .exec(db) + .await?; + Ok(result.rows_affected > 0) + } + + /// Set explicit positions for the given books in the order provided. Books + /// not currently members are skipped. + pub async fn reorder( + db: &DatabaseConnection, + read_list_id: Uuid, + ordered_book_ids: &[Uuid], + ) -> Result<()> { + for (idx, book_id) in ordered_book_ids.iter().enumerate() { + if let Some(link) = ReadListBooks::find() + .filter(read_list_books::Column::ReadListId.eq(read_list_id)) + .filter(read_list_books::Column::BookId.eq(*book_id)) + .one(db) + .await? + { + let mut active = link.into_active_model(); + active.position = Set(idx as i32); + active.update(db).await?; + } + } + Ok(()) + } + + /// Get the member books of a read list in stored order (by position, then + /// insertion time), filtered by the caller's (series-based) visibility. + pub async fn get_books( + db: &DatabaseConnection, + read_list_id: Uuid, + vis: Option<&SeriesVisibility>, + ) -> Result> { + if matches!(vis, Some(v) if v.is_empty_whitelist()) { + return Ok(vec![]); + } + + let ordered_ids: Vec = ReadListBooks::find() + .filter(read_list_books::Column::ReadListId.eq(read_list_id)) + .order_by_asc(read_list_books::Column::Position) + .order_by_asc(read_list_books::Column::CreatedAt) + .all(db) + .await? + .into_iter() + .map(|l| l.book_id) + .collect(); + if ordered_ids.is_empty() { + return Ok(vec![]); + } + + // Visibility is series-based; apply it to the books query. + let query = apply_book_visibility( + Books::find().filter(books::Column::Id.is_in(ordered_ids.clone())), + vis, + ); + let by_id: HashMap = query + .all(db) + .await? + .into_iter() + .map(|b| (b.id, b)) + .collect(); + + Ok(ordered_ids + .iter() + .filter_map(|id| by_id.get(id).cloned()) + .collect()) + } + + /// Count the visible member books of a read list. + pub async fn count_books( + db: &DatabaseConnection, + read_list_id: Uuid, + vis: Option<&SeriesVisibility>, + ) -> Result { + if matches!(vis, Some(v) if v.is_empty_whitelist()) { + return Ok(0); + } + let ids: Vec = ReadListBooks::find() + .filter(read_list_books::Column::ReadListId.eq(read_list_id)) + .all(db) + .await? + .into_iter() + .map(|l| l.book_id) + .collect(); + if ids.is_empty() { + return Ok(0); + } + let query = apply_book_visibility(Books::find().filter(books::Column::Id.is_in(ids)), vis); + Ok(query.count(db).await?) + } + + /// Get the read lists that contain a given book, sorted by name. + pub async fn get_read_lists_for_book( + db: &DatabaseConnection, + book_id: Uuid, + ) -> Result> { + let read_list_ids: Vec = ReadListBooks::find() + .filter(read_list_books::Column::BookId.eq(book_id)) + .all(db) + .await? + .into_iter() + .map(|l| l.read_list_id) + .collect(); + if read_list_ids.is_empty() { + return Ok(vec![]); + } + Ok(ReadLists::find() + .filter(read_lists::Column::Id.is_in(read_list_ids)) + .order_by_asc(read_lists::Column::Name) + .all(db) + .await?) + } + + /// Next position value for a new member (max existing + 1, or 0 when empty). + async fn next_position(db: &DatabaseConnection, read_list_id: Uuid) -> Result { + let positions: Vec = ReadListBooks::find() + .filter(read_list_books::Column::ReadListId.eq(read_list_id)) + .all(db) + .await? + .into_iter() + .map(|l| l.position) + .collect(); + Ok(positions.into_iter().max().map(|m| m + 1).unwrap_or(0)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ScanningStrategy; + use crate::entities::series; + use crate::repositories::{BookRepository, LibraryRepository, SeriesRepository}; + use crate::test_helpers::create_test_db; + + async fn make_book(db: &DatabaseConnection, series_id: Uuid, library_id: Uuid) -> books::Model { + let book = books::Model { + id: Uuid::new_v4(), + series_id, + library_id, + path: format!("/test/{}.cbz", Uuid::new_v4()), + file_name: "book.cbz".to_string(), + file_size: 1024, + file_hash: format!("hash_{}", Uuid::new_v4()), + partial_hash: String::new(), + format: "cbz".to_string(), + page_count: 10, + deleted: false, + analyzed: false, + analysis_error: None, + analysis_errors: None, + modified_at: Utc::now(), + created_at: Utc::now(), + updated_at: Utc::now(), + thumbnail_path: None, + thumbnail_generated_at: None, + koreader_hash: None, + epub_positions: None, + epub_spine_items: None, + }; + BookRepository::create(db, &book, None).await.unwrap() + } + + async fn setup(db: &DatabaseConnection) -> (series::Model, Vec) { + let library = LibraryRepository::create(db, "Lib", "/lib", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(db, library.id, "Series", None) + .await + .unwrap(); + let mut books = Vec::new(); + for _ in 0..3 { + books.push(make_book(db, series.id, library.id).await); + } + (series, books) + } + + #[tokio::test] + async fn test_create_with_summary_and_update() { + let (db, _t) = create_test_db().await; + let conn = db.sea_orm_connection(); + + let rl = ReadListRepository::create(conn, "Civil War", Some("Crossover"), true) + .await + .unwrap(); + assert_eq!(rl.summary.as_deref(), Some("Crossover")); + assert!(rl.ordered); + + let updated = ReadListRepository::update(conn, rl.id, None, Some(None), Some(false)) + .await + .unwrap() + .unwrap(); + assert_eq!(updated.summary, None); + assert!(!updated.ordered); + + assert!(ReadListRepository::delete(conn, rl.id).await.unwrap()); + } + + #[tokio::test] + async fn test_membership_order_and_reorder() { + let (db, _t) = create_test_db().await; + let conn = db.sea_orm_connection(); + let (_series, books) = setup(conn).await; + + let rl = ReadListRepository::create(conn, "List", None, true) + .await + .unwrap(); + for b in &books { + ReadListRepository::add_book(conn, rl.id, b.id) + .await + .unwrap(); + } + // Idempotent re-add. + ReadListRepository::add_book(conn, rl.id, books[0].id) + .await + .unwrap(); + + let members = ReadListRepository::get_books(conn, rl.id, None) + .await + .unwrap(); + assert_eq!(members.len(), 3); + assert_eq!(members[0].id, books[0].id); + + let reversed: Vec = books.iter().rev().map(|b| b.id).collect(); + ReadListRepository::reorder(conn, rl.id, &reversed) + .await + .unwrap(); + let members = ReadListRepository::get_books(conn, rl.id, None) + .await + .unwrap(); + assert_eq!(members[0].id, books[2].id); + + // Containers-for-book lookup. + let lists = ReadListRepository::get_read_lists_for_book(conn, books[0].id) + .await + .unwrap(); + assert_eq!(lists.len(), 1); + assert_eq!(lists[0].id, rl.id); + } + + #[tokio::test] + async fn test_book_visibility_filtering() { + let (db, _t) = create_test_db().await; + let conn = db.sea_orm_connection(); + let (series, books) = setup(conn).await; + + let rl = ReadListRepository::create(conn, "List", None, true) + .await + .unwrap(); + for b in &books { + ReadListRepository::add_book(conn, rl.id, b.id) + .await + .unwrap(); + } + + // Hiding the whole series hides all its books from this viewer. + let vis = SeriesVisibility { + excluded_series_ids: vec![series.id], + allowed_series_ids: None, + }; + assert!( + ReadListRepository::get_books(conn, rl.id, Some(&vis)) + .await + .unwrap() + .is_empty() + ); + assert_eq!( + ReadListRepository::count_books(conn, rl.id, Some(&vis)) + .await + .unwrap(), + 0 + ); + // Without the filter, all three are visible. + assert_eq!( + ReadListRepository::count_books(conn, rl.id, None) + .await + .unwrap(), + 3 + ); + } +} diff --git a/crates/codex-db/src/repositories/want_to_read.rs b/crates/codex-db/src/repositories/want_to_read.rs new file mode 100644 index 00000000..1a1c2b89 --- /dev/null +++ b/crates/codex-db/src/repositories/want_to_read.rs @@ -0,0 +1,329 @@ +//! Repository for the per-user want-to-read queue. +//! +//! Each row flags exactly one series OR one book a user intends to read. The +//! queue is personal: every method scopes to a `user_id`. + +#![allow(dead_code)] + +use std::collections::HashSet; + +use anyhow::Result; +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set, +}; +use uuid::Uuid; + +use crate::entities::{want_to_read, want_to_read::Entity as WantToRead}; + +/// Repository for want-to-read operations. +pub struct WantToReadRepository; + +impl WantToReadRepository { + /// Flag a series for a user. Idempotent: returns the existing row if already + /// queued. + pub async fn add_series( + db: &DatabaseConnection, + user_id: Uuid, + series_id: Uuid, + ) -> Result { + if let Some(existing) = WantToRead::find() + .filter(want_to_read::Column::UserId.eq(user_id)) + .filter(want_to_read::Column::SeriesId.eq(series_id)) + .one(db) + .await? + { + return Ok(existing); + } + let model = want_to_read::ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(user_id), + series_id: Set(Some(series_id)), + book_id: Set(None), + added_at: Set(Utc::now()), + }; + Ok(model.insert(db).await?) + } + + /// Flag a book for a user. Idempotent. + pub async fn add_book( + db: &DatabaseConnection, + user_id: Uuid, + book_id: Uuid, + ) -> Result { + if let Some(existing) = WantToRead::find() + .filter(want_to_read::Column::UserId.eq(user_id)) + .filter(want_to_read::Column::BookId.eq(book_id)) + .one(db) + .await? + { + return Ok(existing); + } + let model = want_to_read::ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(user_id), + series_id: Set(None), + book_id: Set(Some(book_id)), + added_at: Set(Utc::now()), + }; + Ok(model.insert(db).await?) + } + + /// Remove a series from a user's queue. Returns whether a row was removed. + pub async fn remove_series( + db: &DatabaseConnection, + user_id: Uuid, + series_id: Uuid, + ) -> Result { + let result = WantToRead::delete_many() + .filter(want_to_read::Column::UserId.eq(user_id)) + .filter(want_to_read::Column::SeriesId.eq(series_id)) + .exec(db) + .await?; + Ok(result.rows_affected > 0) + } + + /// Remove a book from a user's queue. Returns whether a row was removed. + pub async fn remove_book( + db: &DatabaseConnection, + user_id: Uuid, + book_id: Uuid, + ) -> Result { + let result = WantToRead::delete_many() + .filter(want_to_read::Column::UserId.eq(user_id)) + .filter(want_to_read::Column::BookId.eq(book_id)) + .exec(db) + .await?; + Ok(result.rows_affected > 0) + } + + /// List a user's queue ordered by when each entry was added. + pub async fn list( + db: &DatabaseConnection, + user_id: Uuid, + ascending: bool, + ) -> Result> { + let query = WantToRead::find().filter(want_to_read::Column::UserId.eq(user_id)); + let query = if ascending { + query.order_by_asc(want_to_read::Column::AddedAt) + } else { + query.order_by_desc(want_to_read::Column::AddedAt) + }; + Ok(query.all(db).await?) + } + + /// Whether a series is in the user's queue. + pub async fn is_series_in_queue( + db: &DatabaseConnection, + user_id: Uuid, + series_id: Uuid, + ) -> Result { + Ok(WantToRead::find() + .filter(want_to_read::Column::UserId.eq(user_id)) + .filter(want_to_read::Column::SeriesId.eq(series_id)) + .one(db) + .await? + .is_some()) + } + + /// Whether a book is in the user's queue. + pub async fn is_book_in_queue( + db: &DatabaseConnection, + user_id: Uuid, + book_id: Uuid, + ) -> Result { + Ok(WantToRead::find() + .filter(want_to_read::Column::UserId.eq(user_id)) + .filter(want_to_read::Column::BookId.eq(book_id)) + .one(db) + .await? + .is_some()) + } + + /// Of the given series IDs, return the subset in the user's queue. Batch + /// helper for enriching series DTOs with a `wantToRead` flag. + pub async fn series_ids_in_queue( + db: &DatabaseConnection, + user_id: Uuid, + series_ids: &[Uuid], + ) -> Result> { + if series_ids.is_empty() { + return Ok(HashSet::new()); + } + Ok(WantToRead::find() + .filter(want_to_read::Column::UserId.eq(user_id)) + .filter(want_to_read::Column::SeriesId.is_in(series_ids.to_vec())) + .all(db) + .await? + .into_iter() + .filter_map(|r| r.series_id) + .collect()) + } + + /// Of the given book IDs, return the subset in the user's queue. Batch + /// helper for enriching book DTOs with a `wantToRead` flag. + pub async fn book_ids_in_queue( + db: &DatabaseConnection, + user_id: Uuid, + book_ids: &[Uuid], + ) -> Result> { + if book_ids.is_empty() { + return Ok(HashSet::new()); + } + Ok(WantToRead::find() + .filter(want_to_read::Column::UserId.eq(user_id)) + .filter(want_to_read::Column::BookId.is_in(book_ids.to_vec())) + .all(db) + .await? + .into_iter() + .filter_map(|r| r.book_id) + .collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ScanningStrategy; + use crate::entities::{books, users}; + use crate::repositories::{ + BookRepository, LibraryRepository, SeriesRepository, UserRepository, + }; + use crate::test_helpers::create_test_db; + + async fn make_user(db: &DatabaseConnection, name: &str) -> users::Model { + let now = Utc::now(); + let model = users::Model { + id: Uuid::new_v4(), + username: name.to_string(), + email: format!("{name}@test.test"), + password_hash: "hash".to_string(), + role: "reader".to_string(), + is_active: true, + email_verified: false, + permissions: serde_json::json!([]), + created_at: now, + updated_at: now, + last_login_at: None, + }; + UserRepository::create(db, &model).await.unwrap() + } + + async fn make_series_and_book(db: &DatabaseConnection) -> (Uuid, Uuid) { + let library = LibraryRepository::create(db, "Lib", "/lib", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(db, library.id, "Series", None) + .await + .unwrap(); + let book = books::Model { + id: Uuid::new_v4(), + series_id: series.id, + library_id: library.id, + path: "/test/book.cbz".to_string(), + file_name: "book.cbz".to_string(), + file_size: 1024, + file_hash: format!("hash_{}", Uuid::new_v4()), + partial_hash: String::new(), + format: "cbz".to_string(), + page_count: 10, + deleted: false, + analyzed: false, + analysis_error: None, + analysis_errors: None, + modified_at: Utc::now(), + created_at: Utc::now(), + updated_at: Utc::now(), + thumbnail_path: None, + thumbnail_generated_at: None, + koreader_hash: None, + epub_positions: None, + epub_spine_items: None, + }; + let book = BookRepository::create(db, &book, None).await.unwrap(); + (series.id, book.id) + } + + #[tokio::test] + async fn test_add_remove_and_idempotency() { + let (db, _t) = create_test_db().await; + let conn = db.sea_orm_connection(); + let user = make_user(conn, "alice").await; + let (series_id, book_id) = make_series_and_book(conn).await; + + WantToReadRepository::add_series(conn, user.id, series_id) + .await + .unwrap(); + // Idempotent. + WantToReadRepository::add_series(conn, user.id, series_id) + .await + .unwrap(); + WantToReadRepository::add_book(conn, user.id, book_id) + .await + .unwrap(); + + let queue = WantToReadRepository::list(conn, user.id, false) + .await + .unwrap(); + assert_eq!(queue.len(), 2); + + assert!( + WantToReadRepository::is_series_in_queue(conn, user.id, series_id) + .await + .unwrap() + ); + assert!( + WantToReadRepository::is_book_in_queue(conn, user.id, book_id) + .await + .unwrap() + ); + + assert!( + WantToReadRepository::remove_series(conn, user.id, series_id) + .await + .unwrap() + ); + assert!( + !WantToReadRepository::is_series_in_queue(conn, user.id, series_id) + .await + .unwrap() + ); + assert_eq!( + WantToReadRepository::list(conn, user.id, false) + .await + .unwrap() + .len(), + 1 + ); + } + + #[tokio::test] + async fn test_per_user_isolation_and_batch_lookup() { + let (db, _t) = create_test_db().await; + let conn = db.sea_orm_connection(); + let alice = make_user(conn, "alice").await; + let bob = make_user(conn, "bob").await; + let (series_id, _book_id) = make_series_and_book(conn).await; + + WantToReadRepository::add_series(conn, alice.id, series_id) + .await + .unwrap(); + + // Bob's queue is unaffected. + assert!( + !WantToReadRepository::is_series_in_queue(conn, bob.id, series_id) + .await + .unwrap() + ); + + let in_queue = WantToReadRepository::series_ids_in_queue(conn, alice.id, &[series_id]) + .await + .unwrap(); + assert!(in_queue.contains(&series_id)); + + let bob_in_queue = WantToReadRepository::series_ids_in_queue(conn, bob.id, &[series_id]) + .await + .unwrap(); + assert!(bob_in_queue.is_empty()); + } +} diff --git a/crates/codex-models/src/permissions.rs b/crates/codex-models/src/permissions.rs index e31848f1..e4cdf96d 100644 --- a/crates/codex-models/src/permissions.rs +++ b/crates/codex-models/src/permissions.rs @@ -91,6 +91,16 @@ pub enum Permission { BooksWrite, BooksDelete, + // Collections (shared groupings of series) + CollectionsRead, + CollectionsWrite, + CollectionsDelete, + + // Read lists (shared groupings of books) + ReadListsRead, + ReadListsWrite, + ReadListsDelete, + // Pages (image serving) PagesRead, @@ -134,6 +144,12 @@ impl Permission { Permission::BooksRead => "books:read", Permission::BooksWrite => "books:write", Permission::BooksDelete => "books:delete", + Permission::CollectionsRead => "collections:read", + Permission::CollectionsWrite => "collections:write", + Permission::CollectionsDelete => "collections:delete", + Permission::ReadListsRead => "readlists:read", + Permission::ReadListsWrite => "readlists:write", + Permission::ReadListsDelete => "readlists:delete", Permission::PagesRead => "pages:read", Permission::ProgressRead => "progress:read", Permission::ProgressWrite => "progress:write", @@ -166,6 +182,12 @@ impl FromStr for Permission { "books:read" => Ok(Permission::BooksRead), "books:write" => Ok(Permission::BooksWrite), "books:delete" => Ok(Permission::BooksDelete), + "collections:read" => Ok(Permission::CollectionsRead), + "collections:write" => Ok(Permission::CollectionsWrite), + "collections:delete" => Ok(Permission::CollectionsDelete), + "readlists:read" => Ok(Permission::ReadListsRead), + "readlists:write" => Ok(Permission::ReadListsWrite), + "readlists:delete" => Ok(Permission::ReadListsDelete), "pages:read" => Ok(Permission::PagesRead), "progress:read" => Ok(Permission::ProgressRead), "progress:write" => Ok(Permission::ProgressWrite), @@ -230,6 +252,9 @@ lazy_static::lazy_static! { // Progress tracking set.insert(Permission::ProgressRead); set.insert(Permission::ProgressWrite); + // Browse shared collections and read lists (management is gated separately) + set.insert(Permission::CollectionsRead); + set.insert(Permission::ReadListsRead); // Own API keys set.insert(Permission::ApiKeysRead); set.insert(Permission::ApiKeysWrite); @@ -256,6 +281,11 @@ lazy_static::lazy_static! { // Books (full control) set.insert(Permission::BooksWrite); set.insert(Permission::BooksDelete); + // Collections + read lists (create/modify/delete shared groupings) + set.insert(Permission::CollectionsWrite); + set.insert(Permission::CollectionsDelete); + set.insert(Permission::ReadListsWrite); + set.insert(Permission::ReadListsDelete); // Tasks (view and manage) set.insert(Permission::TasksRead); set.insert(Permission::TasksWrite); @@ -370,15 +400,21 @@ mod tests { // Reader can track reading progress assert!(READER_PERMISSIONS.contains(&Permission::ProgressRead)); assert!(READER_PERMISSIONS.contains(&Permission::ProgressWrite)); + // Reader can browse shared collections and read lists + assert!(READER_PERMISSIONS.contains(&Permission::CollectionsRead)); + assert!(READER_PERMISSIONS.contains(&Permission::ReadListsRead)); // Reader cannot modify content assert!(!READER_PERMISSIONS.contains(&Permission::BooksWrite)); assert!(!READER_PERMISSIONS.contains(&Permission::SeriesWrite)); assert!(!READER_PERMISSIONS.contains(&Permission::LibrariesWrite)); + // Reader cannot manage (write/delete) collections or read lists + assert!(!READER_PERMISSIONS.contains(&Permission::CollectionsWrite)); + assert!(!READER_PERMISSIONS.contains(&Permission::ReadListsWrite)); // Reader cannot manage users or system assert!(!READER_PERMISSIONS.contains(&Permission::UsersRead)); assert!(!READER_PERMISSIONS.contains(&Permission::SystemAdmin)); - assert_eq!(READER_PERMISSIONS.len(), 10); + assert_eq!(READER_PERMISSIONS.len(), 12); } #[test] @@ -401,11 +437,16 @@ mod tests { assert!(MAINTAINER_PERMISSIONS.contains(&Permission::BooksDelete)); // Maintainer can manage tasks assert!(MAINTAINER_PERMISSIONS.contains(&Permission::TasksWrite)); + // Maintainer can manage collections and read lists + assert!(MAINTAINER_PERMISSIONS.contains(&Permission::CollectionsWrite)); + assert!(MAINTAINER_PERMISSIONS.contains(&Permission::CollectionsDelete)); + assert!(MAINTAINER_PERMISSIONS.contains(&Permission::ReadListsWrite)); + assert!(MAINTAINER_PERMISSIONS.contains(&Permission::ReadListsDelete)); // Maintainer cannot manage users or system admin assert!(!MAINTAINER_PERMISSIONS.contains(&Permission::UsersRead)); assert!(!MAINTAINER_PERMISSIONS.contains(&Permission::SystemAdmin)); - assert_eq!(MAINTAINER_PERMISSIONS.len(), 17); + assert_eq!(MAINTAINER_PERMISSIONS.len(), 23); } #[test] @@ -429,7 +470,7 @@ mod tests { // Admin has system admin assert!(ADMIN_PERMISSIONS.contains(&Permission::SystemAdmin)); - assert_eq!(ADMIN_PERMISSIONS.len(), 23); // All permissions + assert_eq!(ADMIN_PERMISSIONS.len(), 29); // All permissions } // ============== UserRole tests ============== From e232bd51eeda20dfac1a6eefec2e0135c5f3994d Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Sat, 13 Jun 2026 17:13:36 -0700 Subject: [PATCH 03/13] feat(api): add want-to-read queue endpoints Expose the per-user want-to-read queue over /api/v1/want-to-read: - GET lists the queue, newest-first by default, with a sort=added_at:asc|desc toggle - POST flags a series or a book (exactly one; idempotent), 400 on a malformed target and 404 when it doesn't exist - DELETE removes a series or book from the queue Every endpoint scopes to the authenticated user; being signed in is sufficient since a user only manages their own queue. Surface queue membership on the existing series and book DTOs via a new wantToRead flag, populated on detail and list endpoints with a single batched lookup per page so cards can render the toggle state without an extra request. Register the new paths and schemas in the OpenAPI doc and regenerate the committed spec. Covered by integration tests. --- crates/codex-api/src/docs.rs | 12 + crates/codex-api/src/routes/v1/dto/book.rs | 8 + crates/codex-api/src/routes/v1/dto/mod.rs | 2 + crates/codex-api/src/routes/v1/dto/series.rs | 8 + .../src/routes/v1/dto/want_to_read.rs | 94 +++++ .../codex-api/src/routes/v1/handlers/books.rs | 7 + .../codex-api/src/routes/v1/handlers/mod.rs | 2 + .../src/routes/v1/handlers/series.rs | 21 ++ .../src/routes/v1/handlers/want_to_read.rs | 174 ++++++++++ crates/codex-api/src/routes/v1/routes/mod.rs | 2 + .../src/routes/v1/routes/want_to_read.rs | 27 ++ docs/api/openapi.json | 291 ++++++++++++++++ tests/api/mod.rs | 1 + tests/api/want_to_read.rs | 324 ++++++++++++++++++ web/openapi.json | 291 ++++++++++++++++ web/src/types/api.generated.ts | 282 +++++++++++++++ 16 files changed, 1546 insertions(+) create mode 100644 crates/codex-api/src/routes/v1/dto/want_to_read.rs create mode 100644 crates/codex-api/src/routes/v1/handlers/want_to_read.rs create mode 100644 crates/codex-api/src/routes/v1/routes/want_to_read.rs create mode 100644 tests/api/want_to_read.rs diff --git a/crates/codex-api/src/docs.rs b/crates/codex-api/src/docs.rs index 9f45da41..cae2bf66 100644 --- a/crates/codex-api/src/docs.rs +++ b/crates/codex-api/src/docs.rs @@ -356,6 +356,12 @@ The following paths are exempt from rate limiting: v1::handlers::mark_series_as_read, v1::handlers::mark_series_as_unread, + // Want to read (per-user queue) endpoints + v1::handlers::list_want_to_read, + v1::handlers::add_want_to_read, + v1::handlers::remove_want_to_read_series, + v1::handlers::remove_want_to_read_book, + // Bulk operations endpoints v1::handlers::bulk_mark_books_as_read, v1::handlers::bulk_mark_books_as_unread, @@ -917,6 +923,12 @@ The following paths are exempt from rate limiting: v1::dto::ReadProgressListResponse, v1::dto::MarkReadResponse, + // Want to read (per-user queue) DTOs + v1::dto::WantToReadEntryDto, + v1::dto::WantToReadListResponse, + v1::dto::AddWantToReadRequest, + v1::dto::WantToReadItemType, + // Bulk operations DTOs v1::dto::BulkBooksRequest, v1::dto::BulkAnalyzeBooksRequest, diff --git a/crates/codex-api/src/routes/v1/dto/book.rs b/crates/codex-api/src/routes/v1/dto/book.rs index 344bf8ea..58b53b7f 100644 --- a/crates/codex-api/src/routes/v1/dto/book.rs +++ b/crates/codex-api/src/routes/v1/dto/book.rs @@ -512,6 +512,14 @@ pub struct BookDto { #[serde(skip_serializing_if = "Option::is_none")] #[schema(example = 42.5)] pub chapter: Option, + + /// Whether the requesting user has this book in their want-to-read queue. + /// + /// `None` when not computed for this response; populated on book list and + /// detail endpoints that have a user context. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = false)] + pub want_to_read: Option, } /// Book list response diff --git a/crates/codex-api/src/routes/v1/dto/mod.rs b/crates/codex-api/src/routes/v1/dto/mod.rs index 8f25040a..1c0c796a 100644 --- a/crates/codex-api/src/routes/v1/dto/mod.rs +++ b/crates/codex-api/src/routes/v1/dto/mod.rs @@ -37,6 +37,7 @@ pub mod tracking; pub mod user; pub mod user_plugins; pub mod user_preferences; +pub mod want_to_read; pub use access_group::*; pub use api_key::*; @@ -79,3 +80,4 @@ pub use user::*; #[allow(unused_imports)] pub use user_plugins::*; pub use user_preferences::*; +pub use want_to_read::*; diff --git a/crates/codex-api/src/routes/v1/dto/series.rs b/crates/codex-api/src/routes/v1/dto/series.rs index 6256bffd..448b66b7 100644 --- a/crates/codex-api/src/routes/v1/dto/series.rs +++ b/crates/codex-api/src/routes/v1/dto/series.rs @@ -144,6 +144,14 @@ pub struct SeriesDto { /// When the series was last updated #[schema(example = "2024-01-15T10:30:00Z")] pub updated_at: DateTime, + + /// Whether the requesting user has this series in their want-to-read queue. + /// + /// `None` when not computed for this response (e.g. list endpoints that + /// don't enrich it); populated on the series detail endpoint. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = false)] + pub want_to_read: Option, } /// Series list response diff --git a/crates/codex-api/src/routes/v1/dto/want_to_read.rs b/crates/codex-api/src/routes/v1/dto/want_to_read.rs new file mode 100644 index 00000000..33bd95a0 --- /dev/null +++ b/crates/codex-api/src/routes/v1/dto/want_to_read.rs @@ -0,0 +1,94 @@ +//! DTOs for the per-user want-to-read queue. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; + +/// What a want-to-read entry points at. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum WantToReadItemType { + Series, + Book, +} + +/// A single entry in a user's want-to-read queue. Exactly one of `series_id` / +/// `book_id` is populated, matching `item_type`. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct WantToReadEntryDto { + /// Queue entry ID. + #[schema(example = "550e8400-e29b-41d4-a716-446655440000")] + pub id: Uuid, + /// Whether this entry flags a series or a book. + pub item_type: WantToReadItemType, + /// The flagged series (set when `item_type` is `series`). + #[serde(skip_serializing_if = "Option::is_none")] + pub series_id: Option, + /// The flagged book (set when `item_type` is `book`). + #[serde(skip_serializing_if = "Option::is_none")] + pub book_id: Option, + /// When the entry was added to the queue. + #[schema(example = "2026-06-15T18:45:00Z")] + pub added_at: DateTime, +} + +impl From for WantToReadEntryDto { + fn from(model: codex_db::entities::want_to_read::Model) -> Self { + let item_type = if model.series_id.is_some() { + WantToReadItemType::Series + } else { + WantToReadItemType::Book + }; + Self { + id: model.id, + item_type, + series_id: model.series_id, + book_id: model.book_id, + added_at: model.added_at, + } + } +} + +/// A user's want-to-read queue. +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct WantToReadListResponse { + /// Queue entries. + pub items: Vec, + /// Total number of entries. + #[schema(example = 7)] + pub total: usize, +} + +/// Request to add an entry to the queue. Exactly one of `series_id` / `book_id` +/// must be provided. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AddWantToReadRequest { + /// Flag a series. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub series_id: Option, + /// Flag a book. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub book_id: Option, +} + +/// Query parameters for listing the queue. +#[derive(Debug, Default, Deserialize, IntoParams)] +#[serde(rename_all = "camelCase")] +#[into_params(parameter_in = Query)] +pub struct WantToReadListQuery { + /// Sort by add time. Accepts `added_at:asc` for oldest-first; any other + /// value (or omitted) yields newest-first (`added_at:desc`). + #[param(example = "added_at:desc")] + pub sort: Option, +} + +impl WantToReadListQuery { + /// Whether to sort ascending (oldest-first). Defaults to descending. + pub fn ascending(&self) -> bool { + matches!(self.sort.as_deref(), Some("added_at:asc") | Some("asc")) + } +} diff --git a/crates/codex-api/src/routes/v1/handlers/books.rs b/crates/codex-api/src/routes/v1/handlers/books.rs index 95c5381e..2aa7d042 100644 --- a/crates/codex-api/src/routes/v1/handlers/books.rs +++ b/crates/codex-api/src/routes/v1/handlers/books.rs @@ -237,6 +237,12 @@ pub async fn books_to_dtos( .map(|(book_id, model)| (book_id, model.into())) .collect(); + // Per-user want-to-read membership for the whole page in one query. + let want_to_read_ids = + codex_db::repositories::WantToReadRepository::book_ids_in_queue(db, user_id, &book_ids) + .await + .map_err(|e| ApiError::Internal(format!("Failed to load want-to-read: {}", e)))?; + // Convert books to DTOs let dtos = books .into_iter() @@ -311,6 +317,7 @@ pub async fn books_to_dtos( reading_direction, volume, chapter, + want_to_read: Some(want_to_read_ids.contains(&book.id)), } }) .collect(); diff --git a/crates/codex-api/src/routes/v1/handlers/mod.rs b/crates/codex-api/src/routes/v1/handlers/mod.rs index d7db3245..6cda0b2c 100644 --- a/crates/codex-api/src/routes/v1/handlers/mod.rs +++ b/crates/codex-api/src/routes/v1/handlers/mod.rs @@ -77,6 +77,7 @@ pub mod tracking; pub mod user_plugins; pub mod user_preferences; pub mod users; +pub mod want_to_read; pub use auth::*; pub use books::*; @@ -93,3 +94,4 @@ pub use read_progress::*; pub use scan::*; pub use series::*; pub use users::*; +pub use want_to_read::*; diff --git a/crates/codex-api/src/routes/v1/handlers/series.rs b/crates/codex-api/src/routes/v1/handlers/series.rs index 200f276f..bca2b927 100644 --- a/crates/codex-api/src/routes/v1/handlers/series.rs +++ b/crates/codex-api/src/routes/v1/handlers/series.rs @@ -42,6 +42,7 @@ use codex_db::repositories::{ GenreRepository, LibraryRepository, ReadProgressRepository, SeriesCoversRepository, SeriesExternalIdRepository, SeriesMetadataRepository, SeriesRepository, SeriesTrackingRepository, SharingTagRepository, TagRepository, UserSeriesRatingRepository, + WantToReadRepository, }; use codex_events::{EntityChangeEvent, EntityEvent, EntityType}; use codex_services::release::upstream_gap::{UpstreamGap, UpstreamGapInputs, compute_upstream_gap}; @@ -210,6 +211,16 @@ async fn series_to_dto( external_ids: &external_ids, }); + // Per-user want-to-read flag (only when we know the requesting user). + let want_to_read = match user_id { + Some(uid) => Some( + WantToReadRepository::is_series_in_queue(db, uid, series.id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to check want-to-read: {}", e)))?, + ), + None => None, + }; + Ok(SeriesDto { id: series.id, library_id: series.library_id, @@ -233,6 +244,7 @@ async fn series_to_dto( upstream_gap_provider, created_at: series.created_at, updated_at: series.updated_at, + want_to_read, }) } @@ -259,6 +271,14 @@ async fn series_to_dtos_batched( .into_iter() .collect(); + // Per-user want-to-read membership for the whole page in one query. + let want_to_read_ids = match user_id { + Some(uid) => WantToReadRepository::series_ids_in_queue(db, uid, &series_ids) + .await + .map_err(|e| ApiError::Internal(format!("Failed to load want-to-read: {}", e)))?, + None => std::collections::HashSet::new(), + }; + // Bound how many of these related-table queries run at once so a single // request cannot hold one pool connection per query and exhaust the pool. let limiter = crate::db_batch::fan_out_limiter(crate::db_batch::configured_fan_out()); @@ -403,6 +423,7 @@ async fn series_to_dtos_batched( upstream_gap_provider, created_at: series.created_at, updated_at: series.updated_at, + want_to_read: user_id.map(|_| want_to_read_ids.contains(&series.id)), }); } diff --git a/crates/codex-api/src/routes/v1/handlers/want_to_read.rs b/crates/codex-api/src/routes/v1/handlers/want_to_read.rs new file mode 100644 index 00000000..3d0d29c4 --- /dev/null +++ b/crates/codex-api/src/routes/v1/handlers/want_to_read.rs @@ -0,0 +1,174 @@ +//! Handlers for the per-user want-to-read queue. +//! +//! The queue is personal: every handler scopes to `auth.user_id`. Being +//! authenticated is sufficient (no extra permission) — a user only ever manages +//! their own queue. + +use super::super::dto::{ + AddWantToReadRequest, WantToReadEntryDto, WantToReadItemType, WantToReadListQuery, + WantToReadListResponse, +}; +use crate::{AppState, error::ApiError, extractors::AuthContext}; +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, +}; +use codex_db::repositories::{BookRepository, SeriesRepository, WantToReadRepository}; +use std::sync::Arc; +use utoipa::OpenApi; +use uuid::Uuid; + +#[derive(OpenApi)] +#[openapi( + paths( + list_want_to_read, + add_want_to_read, + remove_want_to_read_series, + remove_want_to_read_book, + ), + components(schemas( + WantToReadEntryDto, + WantToReadListResponse, + AddWantToReadRequest, + WantToReadItemType, + )), + tags( + (name = "Want to Read", description = "Per-user want-to-read queue endpoints") + ) +)] +#[allow(dead_code)] // OpenAPI documentation struct - referenced by utoipa derive macros +pub struct WantToReadApi; + +/// List the authenticated user's want-to-read queue. +#[utoipa::path( + get, + path = "/api/v1/want-to-read", + params(WantToReadListQuery), + responses( + (status = 200, description = "Queue retrieved", body = WantToReadListResponse), + (status = 401, description = "Unauthorized"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Want to Read" +)] +pub async fn list_want_to_read( + State(state): State>, + auth: AuthContext, + Query(query): Query, +) -> Result, ApiError> { + let entries = WantToReadRepository::list(&state.db, auth.user_id, query.ascending()) + .await + .map_err(|e| ApiError::Internal(format!("Failed to list want-to-read queue: {e}")))?; + + let total = entries.len(); + let items = entries.into_iter().map(WantToReadEntryDto::from).collect(); + Ok(Json(WantToReadListResponse { items, total })) +} + +/// Add a series or book to the authenticated user's queue. +/// +/// Exactly one of `seriesId` / `bookId` must be provided. Idempotent: flagging +/// something already queued returns the existing entry. +#[utoipa::path( + post, + path = "/api/v1/want-to-read", + request_body = AddWantToReadRequest, + responses( + (status = 201, description = "Entry added (or already present)", body = WantToReadEntryDto), + (status = 400, description = "Must provide exactly one of seriesId / bookId"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Series or book not found"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Want to Read" +)] +pub async fn add_want_to_read( + State(state): State>, + auth: AuthContext, + Json(request): Json, +) -> Result<(StatusCode, Json), ApiError> { + let entry = match (request.series_id, request.book_id) { + (Some(series_id), None) => { + SeriesRepository::get_by_id(&state.db, series_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to look up series: {e}")))? + .ok_or_else(|| ApiError::NotFound("Series not found".to_string()))?; + WantToReadRepository::add_series(&state.db, auth.user_id, series_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to add series: {e}")))? + } + (None, Some(book_id)) => { + BookRepository::get_by_id(&state.db, book_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to look up book: {e}")))? + .ok_or_else(|| ApiError::NotFound("Book not found".to_string()))?; + WantToReadRepository::add_book(&state.db, auth.user_id, book_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to add book: {e}")))? + } + _ => { + return Err(ApiError::BadRequest( + "Exactly one of seriesId or bookId must be provided".to_string(), + )); + } + }; + + Ok((StatusCode::CREATED, Json(entry.into()))) +} + +/// Remove a series from the authenticated user's queue. +#[utoipa::path( + delete, + path = "/api/v1/want-to-read/series/{series_id}", + responses( + (status = 204, description = "Removed (or was not present)"), + (status = 401, description = "Unauthorized"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Want to Read" +)] +pub async fn remove_want_to_read_series( + State(state): State>, + auth: AuthContext, + Path(series_id): Path, +) -> Result { + WantToReadRepository::remove_series(&state.db, auth.user_id, series_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to remove series: {e}")))?; + Ok(StatusCode::NO_CONTENT) +} + +/// Remove a book from the authenticated user's queue. +#[utoipa::path( + delete, + path = "/api/v1/want-to-read/books/{book_id}", + responses( + (status = 204, description = "Removed (or was not present)"), + (status = 401, description = "Unauthorized"), + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Want to Read" +)] +pub async fn remove_want_to_read_book( + State(state): State>, + auth: AuthContext, + Path(book_id): Path, +) -> Result { + WantToReadRepository::remove_book(&state.db, auth.user_id, book_id) + .await + .map_err(|e| ApiError::Internal(format!("Failed to remove book: {e}")))?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/crates/codex-api/src/routes/v1/routes/mod.rs b/crates/codex-api/src/routes/v1/routes/mod.rs index fb29d7c6..01a49dc5 100644 --- a/crates/codex-api/src/routes/v1/routes/mod.rs +++ b/crates/codex-api/src/routes/v1/routes/mod.rs @@ -19,6 +19,7 @@ mod tasks; mod user; mod user_plugins; mod users; +mod want_to_read; use crate::extractors::AppState; use axum::Router; @@ -45,6 +46,7 @@ pub fn create_router(state: Arc) -> Router { .merge(recommendations::routes(state.clone())) .merge(releases::routes(state.clone())) .merge(observability::routes(state.clone())) + .merge(want_to_read::routes(state.clone())) // Apply state to all routes .with_state(state) } diff --git a/crates/codex-api/src/routes/v1/routes/want_to_read.rs b/crates/codex-api/src/routes/v1/routes/want_to_read.rs new file mode 100644 index 00000000..0dc34cf4 --- /dev/null +++ b/crates/codex-api/src/routes/v1/routes/want_to_read.rs @@ -0,0 +1,27 @@ +//! Want-to-read routes +//! +//! Per-user on-deck queue. All routes require authentication; each scopes to the +//! authenticated user. + +use super::super::handlers; +use crate::extractors::AppState; +use axum::{ + Router, + routing::{delete, get, post}, +}; +use std::sync::Arc; + +/// Create want-to-read routes. +pub fn routes(_state: Arc) -> Router> { + Router::new() + .route("/want-to-read", get(handlers::list_want_to_read)) + .route("/want-to-read", post(handlers::add_want_to_read)) + .route( + "/want-to-read/series/{series_id}", + delete(handlers::remove_want_to_read_series), + ) + .route( + "/want-to-read/books/{book_id}", + delete(handlers::remove_want_to_read_book), + ) +} diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 348387f2..450f5330 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -16429,6 +16429,169 @@ ] } }, + "/api/v1/want-to-read": { + "get": { + "tags": [ + "Want to Read" + ], + "summary": "List the authenticated user's want-to-read queue.", + "operationId": "list_want_to_read", + "parameters": [ + { + "name": "sort", + "in": "query", + "description": "Sort by add time. Accepts `added_at:asc` for oldest-first; any other\nvalue (or omitted) yields newest-first (`added_at:desc`).", + "required": false, + "schema": { + "type": "string" + }, + "example": "added_at:desc" + } + ], + "responses": { + "200": { + "description": "Queue retrieved", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WantToReadListResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "post": { + "tags": [ + "Want to Read" + ], + "summary": "Add a series or book to the authenticated user's queue.", + "description": "Exactly one of `seriesId` / `bookId` must be provided. Idempotent: flagging\nsomething already queued returns the existing entry.", + "operationId": "add_want_to_read", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddWantToReadRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Entry added (or already present)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WantToReadEntryDto" + } + } + } + }, + "400": { + "description": "Must provide exactly one of seriesId / bookId" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Series or book not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/want-to-read/books/{book_id}": { + "delete": { + "tags": [ + "Want to Read" + ], + "summary": "Remove a book from the authenticated user's queue.", + "operationId": "remove_want_to_read_book", + "parameters": [ + { + "name": "book_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Removed (or was not present)" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/want-to-read/series/{series_id}": { + "delete": { + "tags": [ + "Want to Read" + ], + "summary": "Remove a series from the authenticated user's queue.", + "operationId": "remove_want_to_read_series", + "parameters": [ + { + "name": "series_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Removed (or was not present)" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, "/health": { "get": { "tags": [ @@ -19518,6 +19681,28 @@ } } }, + "AddWantToReadRequest": { + "type": "object", + "description": "Request to add an entry to the queue. Exactly one of `series_id` / `book_id`\nmust be provided.", + "properties": { + "bookId": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "Flag a book." + }, + "seriesId": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "Flag a series." + } + } + }, "AdjacentBooksResponse": { "type": "object", "description": "Response containing adjacent books in the same series\n\nReturns the previous and next books relative to the requested book,\nordered by book number within the series.", @@ -20605,6 +20790,14 @@ "format": "int32", "description": "Volume number from book metadata", "example": 1 + }, + "wantToRead": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the requesting user has this book in their want-to-read queue.\n\n`None` when not computed for this response; populated on book list and\ndetail endpoints that have a user context.", + "example": false } } }, @@ -31906,6 +32099,14 @@ "format": "int32", "description": "Volume number from book metadata", "example": 1 + }, + "wantToRead": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the requesting user has this book in their want-to-read queue.\n\n`None` when not computed for this response; populated on book list and\ndetail endpoints that have a user context.", + "example": false } } }, @@ -32600,6 +32801,14 @@ "description": "Number of books in this series classified as a complete volume\n(`volume IS NOT NULL AND chapter IS NULL`).\n\nDistinct from `bookCount`: a chapter inside a volume (`v15 c126`)\ncounts as a chapter, not a volume. `None` when no books exist;\n`Some(0)` when books exist but none are complete volumes.", "example": 14 }, + "wantToRead": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the requesting user has this series in their want-to-read queue.\n\n`None` when not computed for this response (e.g. list endpoints that\ndon't enrich it); populated on the series detail endpoint.", + "example": false + }, "year": { "type": [ "integer", @@ -37932,6 +38141,14 @@ "description": "Number of books in this series classified as a complete volume\n(`volume IS NOT NULL AND chapter IS NULL`).\n\nDistinct from `bookCount`: a chapter inside a volume (`v15 c126`)\ncounts as a chapter, not a volume. `None` when no books exist;\n`Some(0)` when books exist but none are complete volumes.", "example": 14 }, + "wantToRead": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the requesting user has this series in their want-to-read queue.\n\n`None` when not computed for this response (e.g. list endpoints that\ndon't enrich it); populated on the series detail endpoint.", + "example": false + }, "year": { "type": [ "integer", @@ -42934,6 +43151,80 @@ "description": "User information" } } + }, + "WantToReadEntryDto": { + "type": "object", + "description": "A single entry in a user's want-to-read queue. Exactly one of `series_id` /\n`book_id` is populated, matching `item_type`.", + "required": [ + "id", + "itemType", + "addedAt" + ], + "properties": { + "addedAt": { + "type": "string", + "format": "date-time", + "description": "When the entry was added to the queue.", + "example": "2026-06-15T18:45:00Z" + }, + "bookId": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "The flagged book (set when `item_type` is `book`)." + }, + "id": { + "type": "string", + "format": "uuid", + "description": "Queue entry ID.", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "itemType": { + "$ref": "#/components/schemas/WantToReadItemType", + "description": "Whether this entry flags a series or a book." + }, + "seriesId": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "The flagged series (set when `item_type` is `series`)." + } + } + }, + "WantToReadItemType": { + "type": "string", + "description": "What a want-to-read entry points at.", + "enum": [ + "series", + "book" + ] + }, + "WantToReadListResponse": { + "type": "object", + "description": "A user's want-to-read queue.", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WantToReadEntryDto" + }, + "description": "Queue entries." + }, + "total": { + "type": "integer", + "description": "Total number of entries.", + "example": 7, + "minimum": 0 + } + } } }, "securitySchemes": { diff --git a/tests/api/mod.rs b/tests/api/mod.rs index ece3a03b..0662e46e 100644 --- a/tests/api/mod.rs +++ b/tests/api/mod.rs @@ -60,3 +60,4 @@ mod tracking; mod user_plugins; mod user_preferences; mod user_ratings; +mod want_to_read; diff --git a/tests/api/want_to_read.rs b/tests/api/want_to_read.rs new file mode 100644 index 00000000..4b290bc5 --- /dev/null +++ b/tests/api/want_to_read.rs @@ -0,0 +1,324 @@ +#[path = "../common/mod.rs"] +mod common; + +use codex::api::error::ErrorResponse; +use codex::api::routes::v1::dto::{ + SeriesDto, WantToReadEntryDto, WantToReadItemType, WantToReadListResponse, +}; +use codex::db::ScanningStrategy; +use codex::db::repositories::{ + BookRepository, LibraryRepository, SeriesRepository, UserRepository, +}; +use codex::utils::password; +use common::*; +use hyper::StatusCode; + +/// Create a user and return (id, token). +async fn user_and_token( + db: &sea_orm::DatabaseConnection, + state: &codex::api::extractors::AuthState, + username: &str, + is_admin: bool, +) -> (uuid::Uuid, String) { + let password_hash = password::hash_password("pw123456").unwrap(); + let user = create_test_user( + username, + &format!("{username}@example.com"), + &password_hash, + is_admin, + ); + let created = UserRepository::create(db, &user).await.unwrap(); + let token = state + .jwt_service + .generate_token(created.id, created.username.clone(), created.get_role()) + .unwrap(); + (created.id, token) +} + +async fn a_book( + db: &sea_orm::DatabaseConnection, + series_id: uuid::Uuid, + library_id: uuid::Uuid, +) -> codex::db::entities::books::Model { + use chrono::Utc; + let book = codex::db::entities::books::Model { + id: uuid::Uuid::new_v4(), + series_id, + library_id, + path: format!("/test/{}.cbz", uuid::Uuid::new_v4()), + file_name: "book.cbz".to_string(), + file_size: 1024, + file_hash: format!("hash_{}", uuid::Uuid::new_v4()), + partial_hash: String::new(), + format: "cbz".to_string(), + page_count: 10, + deleted: false, + analyzed: false, + analysis_error: None, + analysis_errors: None, + modified_at: Utc::now(), + created_at: Utc::now(), + updated_at: Utc::now(), + thumbnail_path: None, + thumbnail_generated_at: None, + koreader_hash: None, + epub_positions: None, + epub_spine_items: None, + }; + BookRepository::create(db, &book, None).await.unwrap() +} + +async fn library_and_series( + db: &sea_orm::DatabaseConnection, +) -> ( + codex::db::entities::libraries::Model, + codex::db::entities::series::Model, +) { + let library = LibraryRepository::create(db, "Lib", "/test", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(db, library.id, "Series", None) + .await + .unwrap(); + (library, series) +} + +#[tokio::test] +async fn test_add_series_and_book_to_queue() { + let (db, _t) = setup_test_db().await; + let (_library, series) = library_and_series(&db).await; + let book = a_book(&db, series.id, series.library_id).await; + + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "alice", false).await; + let app = create_test_router(state).await; + + // Add a series. + let req = post_json_request_with_auth( + "/api/v1/want-to-read", + &serde_json::json!({ "seriesId": series.id }), + &token, + ); + let (status, entry): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::CREATED); + let entry = entry.unwrap(); + assert_eq!(entry.item_type, WantToReadItemType::Series); + assert_eq!(entry.series_id, Some(series.id)); + assert_eq!(entry.book_id, None); + + // Add a book. + let req = post_json_request_with_auth( + "/api/v1/want-to-read", + &serde_json::json!({ "bookId": book.id }), + &token, + ); + let (status, entry): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::CREATED); + assert_eq!(entry.unwrap().item_type, WantToReadItemType::Book); + + // List the queue. + let req = get_request_with_auth("/api/v1/want-to-read", &token); + let (status, list): (StatusCode, Option) = + make_json_request(app, req).await; + assert_eq!(status, StatusCode::OK); + let list = list.unwrap(); + assert_eq!(list.total, 2); + assert_eq!(list.items.len(), 2); +} + +#[tokio::test] +async fn test_add_requires_exactly_one_target() { + let (db, _t) = setup_test_db().await; + let (_library, series) = library_and_series(&db).await; + let book = a_book(&db, series.id, series.library_id).await; + + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "alice", false).await; + let app = create_test_router(state).await; + + // Neither provided -> 400. + let req = post_json_request_with_auth("/api/v1/want-to-read", &serde_json::json!({}), &token); + let (status, _): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + + // Both provided -> 400. + let req = post_json_request_with_auth( + "/api/v1/want-to-read", + &serde_json::json!({ "seriesId": series.id, "bookId": book.id }), + &token, + ); + let (status, _): (StatusCode, Option) = make_json_request(app, req).await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn test_add_nonexistent_returns_404() { + let (db, _t) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "alice", false).await; + let app = create_test_router(state).await; + + let req = post_json_request_with_auth( + "/api/v1/want-to-read", + &serde_json::json!({ "seriesId": uuid::Uuid::new_v4() }), + &token, + ); + let (status, _): (StatusCode, Option) = make_json_request(app, req).await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_idempotent_add_and_remove() { + let (db, _t) = setup_test_db().await; + let (_library, series) = library_and_series(&db).await; + + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "alice", false).await; + let app = create_test_router(state).await; + + let body = serde_json::json!({ "seriesId": series.id }); + for _ in 0..2 { + let req = post_json_request_with_auth("/api/v1/want-to-read", &body, &token); + let (status, _): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::CREATED); + } + + // Still only one entry. + let req = get_request_with_auth("/api/v1/want-to-read", &token); + let (_s, list): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(list.unwrap().total, 1); + + // Remove it. + let req = delete_request_with_auth( + &format!("/api/v1/want-to-read/series/{}", series.id), + &token, + ); + let (status, _): (StatusCode, Option) = make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::NO_CONTENT); + + let req = get_request_with_auth("/api/v1/want-to-read", &token); + let (_s, list): (StatusCode, Option) = + make_json_request(app, req).await; + assert_eq!(list.unwrap().total, 0); +} + +#[tokio::test] +async fn test_queue_is_per_user() { + let (db, _t) = setup_test_db().await; + let (_library, series) = library_and_series(&db).await; + + let state = create_test_auth_state(db.clone()).await; + let (_alice, alice_token) = user_and_token(&db, &state, "alice", false).await; + let (_bob, bob_token) = user_and_token(&db, &state, "bob", false).await; + let app = create_test_router(state).await; + + // Alice adds a series. + let req = post_json_request_with_auth( + "/api/v1/want-to-read", + &serde_json::json!({ "seriesId": series.id }), + &alice_token, + ); + let (status, _): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::CREATED); + + // Bob's queue is empty. + let req = get_request_with_auth("/api/v1/want-to-read", &bob_token); + let (_s, list): (StatusCode, Option) = + make_json_request(app, req).await; + assert_eq!(list.unwrap().total, 0); +} + +#[tokio::test] +async fn test_list_sort_direction() { + let (db, _t) = setup_test_db().await; + let library = LibraryRepository::create(&db, "Lib", "/test", ScanningStrategy::Default) + .await + .unwrap(); + let first = SeriesRepository::create(&db, library.id, "First", None) + .await + .unwrap(); + let second = SeriesRepository::create(&db, library.id, "Second", None) + .await + .unwrap(); + + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "alice", false).await; + let app = create_test_router(state).await; + + // Add `first`, then `second`, with a gap so added_at is strictly ordered. + let req = post_json_request_with_auth( + "/api/v1/want-to-read", + &serde_json::json!({ "seriesId": first.id }), + &token, + ); + let _: (StatusCode, Option) = make_json_request(app.clone(), req).await; + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let req = post_json_request_with_auth( + "/api/v1/want-to-read", + &serde_json::json!({ "seriesId": second.id }), + &token, + ); + let _: (StatusCode, Option) = make_json_request(app.clone(), req).await; + + // Default (desc): newest first => `second` before `first`. + let req = get_request_with_auth("/api/v1/want-to-read", &token); + let (_s, list): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + let desc = list.unwrap().items; + assert_eq!(desc[0].series_id, Some(second.id)); + assert_eq!(desc[1].series_id, Some(first.id)); + + // Ascending: oldest first => `first` before `second`. + let req = get_request_with_auth("/api/v1/want-to-read?sort=added_at:asc", &token); + let (_s, list): (StatusCode, Option) = + make_json_request(app, req).await; + let asc = list.unwrap().items; + assert_eq!(asc[0].series_id, Some(first.id)); + assert_eq!(asc[1].series_id, Some(second.id)); +} + +#[tokio::test] +async fn test_requires_authentication() { + let (db, _t) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let app = create_test_router(state).await; + + let req = get_request("/api/v1/want-to-read"); + let (status, _): (StatusCode, Option) = make_json_request(app, req).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_series_dto_exposes_want_to_read_flag() { + let (db, _t) = setup_test_db().await; + let (_library, series) = library_and_series(&db).await; + + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "alice", true).await; + let app = create_test_router(state).await; + + // Before flagging, the detail DTO reports false. + let req = get_request_with_auth(&format!("/api/v1/series/{}", series.id), &token); + let (status, dto): (StatusCode, Option) = make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(dto.unwrap().want_to_read, Some(false)); + + // Flag it. + let req = post_json_request_with_auth( + "/api/v1/want-to-read", + &serde_json::json!({ "seriesId": series.id }), + &token, + ); + let _: (StatusCode, Option) = make_json_request(app.clone(), req).await; + + // Now the detail DTO reports true. + let req = get_request_with_auth(&format!("/api/v1/series/{}", series.id), &token); + let (_s, dto): (StatusCode, Option) = make_json_request(app, req).await; + assert_eq!(dto.unwrap().want_to_read, Some(true)); +} diff --git a/web/openapi.json b/web/openapi.json index 348387f2..450f5330 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -16429,6 +16429,169 @@ ] } }, + "/api/v1/want-to-read": { + "get": { + "tags": [ + "Want to Read" + ], + "summary": "List the authenticated user's want-to-read queue.", + "operationId": "list_want_to_read", + "parameters": [ + { + "name": "sort", + "in": "query", + "description": "Sort by add time. Accepts `added_at:asc` for oldest-first; any other\nvalue (or omitted) yields newest-first (`added_at:desc`).", + "required": false, + "schema": { + "type": "string" + }, + "example": "added_at:desc" + } + ], + "responses": { + "200": { + "description": "Queue retrieved", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WantToReadListResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "post": { + "tags": [ + "Want to Read" + ], + "summary": "Add a series or book to the authenticated user's queue.", + "description": "Exactly one of `seriesId` / `bookId` must be provided. Idempotent: flagging\nsomething already queued returns the existing entry.", + "operationId": "add_want_to_read", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddWantToReadRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Entry added (or already present)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WantToReadEntryDto" + } + } + } + }, + "400": { + "description": "Must provide exactly one of seriesId / bookId" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Series or book not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/want-to-read/books/{book_id}": { + "delete": { + "tags": [ + "Want to Read" + ], + "summary": "Remove a book from the authenticated user's queue.", + "operationId": "remove_want_to_read_book", + "parameters": [ + { + "name": "book_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Removed (or was not present)" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/want-to-read/series/{series_id}": { + "delete": { + "tags": [ + "Want to Read" + ], + "summary": "Remove a series from the authenticated user's queue.", + "operationId": "remove_want_to_read_series", + "parameters": [ + { + "name": "series_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Removed (or was not present)" + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, "/health": { "get": { "tags": [ @@ -19518,6 +19681,28 @@ } } }, + "AddWantToReadRequest": { + "type": "object", + "description": "Request to add an entry to the queue. Exactly one of `series_id` / `book_id`\nmust be provided.", + "properties": { + "bookId": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "Flag a book." + }, + "seriesId": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "Flag a series." + } + } + }, "AdjacentBooksResponse": { "type": "object", "description": "Response containing adjacent books in the same series\n\nReturns the previous and next books relative to the requested book,\nordered by book number within the series.", @@ -20605,6 +20790,14 @@ "format": "int32", "description": "Volume number from book metadata", "example": 1 + }, + "wantToRead": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the requesting user has this book in their want-to-read queue.\n\n`None` when not computed for this response; populated on book list and\ndetail endpoints that have a user context.", + "example": false } } }, @@ -31906,6 +32099,14 @@ "format": "int32", "description": "Volume number from book metadata", "example": 1 + }, + "wantToRead": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the requesting user has this book in their want-to-read queue.\n\n`None` when not computed for this response; populated on book list and\ndetail endpoints that have a user context.", + "example": false } } }, @@ -32600,6 +32801,14 @@ "description": "Number of books in this series classified as a complete volume\n(`volume IS NOT NULL AND chapter IS NULL`).\n\nDistinct from `bookCount`: a chapter inside a volume (`v15 c126`)\ncounts as a chapter, not a volume. `None` when no books exist;\n`Some(0)` when books exist but none are complete volumes.", "example": 14 }, + "wantToRead": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the requesting user has this series in their want-to-read queue.\n\n`None` when not computed for this response (e.g. list endpoints that\ndon't enrich it); populated on the series detail endpoint.", + "example": false + }, "year": { "type": [ "integer", @@ -37932,6 +38141,14 @@ "description": "Number of books in this series classified as a complete volume\n(`volume IS NOT NULL AND chapter IS NULL`).\n\nDistinct from `bookCount`: a chapter inside a volume (`v15 c126`)\ncounts as a chapter, not a volume. `None` when no books exist;\n`Some(0)` when books exist but none are complete volumes.", "example": 14 }, + "wantToRead": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the requesting user has this series in their want-to-read queue.\n\n`None` when not computed for this response (e.g. list endpoints that\ndon't enrich it); populated on the series detail endpoint.", + "example": false + }, "year": { "type": [ "integer", @@ -42934,6 +43151,80 @@ "description": "User information" } } + }, + "WantToReadEntryDto": { + "type": "object", + "description": "A single entry in a user's want-to-read queue. Exactly one of `series_id` /\n`book_id` is populated, matching `item_type`.", + "required": [ + "id", + "itemType", + "addedAt" + ], + "properties": { + "addedAt": { + "type": "string", + "format": "date-time", + "description": "When the entry was added to the queue.", + "example": "2026-06-15T18:45:00Z" + }, + "bookId": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "The flagged book (set when `item_type` is `book`)." + }, + "id": { + "type": "string", + "format": "uuid", + "description": "Queue entry ID.", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "itemType": { + "$ref": "#/components/schemas/WantToReadItemType", + "description": "Whether this entry flags a series or a book." + }, + "seriesId": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "The flagged series (set when `item_type` is `series`)." + } + } + }, + "WantToReadItemType": { + "type": "string", + "description": "What a want-to-read entry points at.", + "enum": [ + "series", + "book" + ] + }, + "WantToReadListResponse": { + "type": "object", + "description": "A user's want-to-read queue.", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WantToReadEntryDto" + }, + "description": "Queue entries." + }, + "total": { + "type": "integer", + "description": "Total number of entries.", + "example": 7, + "minimum": 0 + } + } } }, "securitySchemes": { diff --git a/web/src/types/api.generated.ts b/web/src/types/api.generated.ts index 95522b80..45de6d97 100644 --- a/web/src/types/api.generated.ts +++ b/web/src/types/api.generated.ts @@ -5569,6 +5569,62 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/want-to-read": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List the authenticated user's want-to-read queue. */ + get: operations["list_want_to_read"]; + put?: never; + /** + * Add a series or book to the authenticated user's queue. + * @description Exactly one of `seriesId` / `bookId` must be provided. Idempotent: flagging + * something already queued returns the existing entry. + */ + post: operations["add_want_to_read"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/want-to-read/books/{book_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Remove a book from the authenticated user's queue. */ + delete: operations["remove_want_to_read_book"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/want-to-read/series/{series_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Remove a series from the authenticated user's queue. */ + delete: operations["remove_want_to_read_series"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/health": { parameters: { query?: never; @@ -7127,6 +7183,22 @@ export interface components { */ name: string; }; + /** + * @description Request to add an entry to the queue. Exactly one of `series_id` / `book_id` + * must be provided. + */ + AddWantToReadRequest: { + /** + * Format: uuid + * @description Flag a book. + */ + bookId?: string | null; + /** + * Format: uuid + * @description Flag a series. + */ + seriesId?: string | null; + }; /** * @description Response containing adjacent books in the same series * @@ -7734,6 +7806,14 @@ export interface components { * @example 1 */ volume?: number | null; + /** + * @description Whether the requesting user has this book in their want-to-read queue. + * + * `None` when not computed for this response; populated on book list and + * detail endpoints that have a user context. + * @example false + */ + wantToRead?: boolean | null; }; /** @description A single error for a book */ BookErrorDto: { @@ -13705,6 +13785,14 @@ export interface components { * @example 1 */ volume?: number | null; + /** + * @description Whether the requesting user has this book in their want-to-read queue. + * + * `None` when not computed for this response; populated on book list and + * detail endpoints that have a user context. + * @example false + */ + wantToRead?: boolean | null; }[]; /** @description HATEOAS navigation links */ links: components["schemas"]["PaginationLinks"]; @@ -14184,6 +14272,14 @@ export interface components { * @example 14 */ volumesOwned?: number | null; + /** + * @description Whether the requesting user has this series in their want-to-read queue. + * + * `None` when not computed for this response (e.g. list endpoints that + * don't enrich it); populated on the series detail endpoint. + * @example false + */ + wantToRead?: boolean | null; /** * Format: int32 * @description Release year @@ -17267,6 +17363,14 @@ export interface components { * @example 14 */ volumesOwned?: number | null; + /** + * @description Whether the requesting user has this series in their want-to-read queue. + * + * `None` when not computed for this response (e.g. list endpoints that + * don't enrich it); populated on the series detail endpoint. + * @example false + */ + wantToRead?: boolean | null; /** * Format: int32 * @description Release year @@ -19935,6 +20039,51 @@ export interface components { /** @description User information */ user: components["schemas"]["UserInfo"]; }; + /** + * @description A single entry in a user's want-to-read queue. Exactly one of `series_id` / + * `book_id` is populated, matching `item_type`. + */ + WantToReadEntryDto: { + /** + * Format: date-time + * @description When the entry was added to the queue. + * @example 2026-06-15T18:45:00Z + */ + addedAt: string; + /** + * Format: uuid + * @description The flagged book (set when `item_type` is `book`). + */ + bookId?: string | null; + /** + * Format: uuid + * @description Queue entry ID. + * @example 550e8400-e29b-41d4-a716-446655440000 + */ + id: string; + /** @description Whether this entry flags a series or a book. */ + itemType: components["schemas"]["WantToReadItemType"]; + /** + * Format: uuid + * @description The flagged series (set when `item_type` is `series`). + */ + seriesId?: string | null; + }; + /** + * @description What a want-to-read entry points at. + * @enum {string} + */ + WantToReadItemType: "series" | "book"; + /** @description A user's want-to-read queue. */ + WantToReadListResponse: { + /** @description Queue entries. */ + items: components["schemas"]["WantToReadEntryDto"][]; + /** + * @description Total number of entries. + * @example 7 + */ + total: number; + }; }; responses: never; parameters: never; @@ -32357,6 +32506,139 @@ export interface operations { }; }; }; + list_want_to_read: { + parameters: { + query?: { + /** + * @description Sort by add time. Accepts `added_at:asc` for oldest-first; any other + * value (or omitted) yields newest-first (`added_at:desc`). + * @example added_at:desc + */ + sort?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Queue retrieved */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WantToReadListResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + add_want_to_read: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AddWantToReadRequest"]; + }; + }; + responses: { + /** @description Entry added (or already present) */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WantToReadEntryDto"]; + }; + }; + /** @description Must provide exactly one of seriesId / bookId */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Series or book not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + remove_want_to_read_book: { + parameters: { + query?: never; + header?: never; + path: { + book_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Removed (or was not present) */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + remove_want_to_read_series: { + parameters: { + query?: never; + header?: never; + path: { + series_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Removed (or was not present) */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; health_check: { parameters: { query?: never; From b7df1bbcdaa607fda0b9dac7985509f27203480f Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Mon, 15 Jun 2026 16:57:36 -0700 Subject: [PATCH 04/13] feat(web): add Want to Read page and toggle Add the frontend for the per-user want-to-read queue: - A Want to Read page with a Newest/Oldest sort toggle and a responsive grid that reuses the standard media card, with per-entry removal and empty/loading states - A bookmark toggle on the series and book detail pages, driven by the DTO's wantToRead flag, that adds/removes the item and refreshes state - A sidebar nav link and route, plus the API client and query hooks Also surface wantToRead on the full series and book responses (the flattened DTOs the detail pages fetch), populated with one batched query per page, and regenerate the OpenAPI spec and TypeScript types. Covered by a component test for the toggle. --- crates/codex-api/src/routes/v1/dto/book.rs | 5 + crates/codex-api/src/routes/v1/dto/series.rs | 5 + .../codex-api/src/routes/v1/handlers/books.rs | 7 + .../src/routes/v1/handlers/series.rs | 9 + docs/api/openapi.json | 16 + web/openapi.json | 16 + web/src/App.tsx | 12 + web/src/api/wantToRead.ts | 50 +++ web/src/components/WantToReadButton.test.tsx | 52 +++ web/src/components/WantToReadButton.tsx | 58 ++++ web/src/components/layout/Sidebar.tsx | 9 + web/src/hooks/useWantToRead.ts | 71 +++++ web/src/pages/BookDetail.tsx | 159 +++++----- web/src/pages/SeriesDetail.tsx | 295 +++++++++--------- web/src/pages/WantToRead.tsx | 140 +++++++++ web/src/types/api.generated.ts | 10 + 16 files changed, 698 insertions(+), 216 deletions(-) create mode 100644 web/src/api/wantToRead.ts create mode 100644 web/src/components/WantToReadButton.test.tsx create mode 100644 web/src/components/WantToReadButton.tsx create mode 100644 web/src/hooks/useWantToRead.ts create mode 100644 web/src/pages/WantToRead.tsx diff --git a/crates/codex-api/src/routes/v1/dto/book.rs b/crates/codex-api/src/routes/v1/dto/book.rs index 58b53b7f..8c69e6fa 100644 --- a/crates/codex-api/src/routes/v1/dto/book.rs +++ b/crates/codex-api/src/routes/v1/dto/book.rs @@ -2123,6 +2123,11 @@ pub struct FullBookResponse { /// When the book was last updated #[schema(example = "2024-01-15T10:30:00Z")] pub updated_at: DateTime, + + /// Whether the requesting user has this book in their want-to-read queue. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = false)] + pub want_to_read: Option, } // ============================================================================= diff --git a/crates/codex-api/src/routes/v1/dto/series.rs b/crates/codex-api/src/routes/v1/dto/series.rs index 448b66b7..21c24ceb 100644 --- a/crates/codex-api/src/routes/v1/dto/series.rs +++ b/crates/codex-api/src/routes/v1/dto/series.rs @@ -1298,6 +1298,11 @@ pub struct FullSeriesResponse { /// When the series was last updated #[schema(example = "2024-01-15T10:30:00Z")] pub updated_at: DateTime, + + /// Whether the requesting user has this series in their want-to-read queue. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(example = false)] + pub want_to_read: Option, } /// Request to update metadata lock states diff --git a/crates/codex-api/src/routes/v1/handlers/books.rs b/crates/codex-api/src/routes/v1/handlers/books.rs index 2aa7d042..fddb1a37 100644 --- a/crates/codex-api/src/routes/v1/handlers/books.rs +++ b/crates/codex-api/src/routes/v1/handlers/books.rs @@ -355,6 +355,12 @@ pub async fn books_to_full_dtos_batched( .into_iter() .collect(); + // Per-user want-to-read membership for the whole page in one query. + let want_to_read_ids = + codex_db::repositories::WantToReadRepository::book_ids_in_queue(db, user_id, &book_ids) + .await + .map_err(|e| ApiError::Internal(format!("Failed to load want-to-read: {}", e)))?; + // Fetch all related data in parallel, bounded so a single request cannot // hold one pool connection per query and exhaust the pool. let limiter = crate::db_batch::fan_out_limiter(crate::db_batch::configured_fan_out()); @@ -723,6 +729,7 @@ pub async fn books_to_full_dtos_batched( tags: book_tags, created_at: book.created_at, updated_at: book.updated_at, + want_to_read: Some(want_to_read_ids.contains(&book.id)), }); } diff --git a/crates/codex-api/src/routes/v1/handlers/series.rs b/crates/codex-api/src/routes/v1/handlers/series.rs index bca2b927..a686153a 100644 --- a/crates/codex-api/src/routes/v1/handlers/series.rs +++ b/crates/codex-api/src/routes/v1/handlers/series.rs @@ -454,6 +454,14 @@ async fn series_to_full_dtos_batched( .into_iter() .collect(); + // Per-user want-to-read membership for the whole page in one query. + let want_to_read_ids = match user_id { + Some(uid) => WantToReadRepository::series_ids_in_queue(db, uid, &series_ids) + .await + .map_err(|e| ApiError::Internal(format!("Failed to load want-to-read: {}", e)))?, + None => std::collections::HashSet::new(), + }; + // Fetch all related data using batched queries, but bound how many run at // once so a single request cannot hold one pool connection per query and // exhaust the pool under concurrent load. @@ -774,6 +782,7 @@ async fn series_to_full_dtos_batched( external_ids: ext_id_dtos, created_at: series.created_at, updated_at: series.updated_at, + want_to_read: user_id.map(|_| want_to_read_ids.contains(&series.id)), }); } diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 450f5330..d14beb17 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -27176,6 +27176,14 @@ "format": "date-time", "description": "When the book was last updated", "example": "2024-01-15T10:30:00Z" + }, + "wantToRead": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the requesting user has this book in their want-to-read queue.", + "example": false } } }, @@ -27558,6 +27566,14 @@ "format": "int64", "description": "Number of books classified as a complete volume (volume set, chapter null).\nSee `SeriesDto::volumes_owned` for semantics.", "example": 14 + }, + "wantToRead": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the requesting user has this series in their want-to-read queue.", + "example": false } } }, diff --git a/web/openapi.json b/web/openapi.json index 450f5330..d14beb17 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -27176,6 +27176,14 @@ "format": "date-time", "description": "When the book was last updated", "example": "2024-01-15T10:30:00Z" + }, + "wantToRead": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the requesting user has this book in their want-to-read queue.", + "example": false } } }, @@ -27558,6 +27566,14 @@ "format": "int64", "description": "Number of books classified as a complete volume (volume set, chapter null).\nSee `SeriesDto::volumes_owned` for semantics.", "example": 14 + }, + "wantToRead": { + "type": [ + "boolean", + "null" + ], + "description": "Whether the requesting user has this series in their want-to-read queue.", + "example": false } } }, diff --git a/web/src/App.tsx b/web/src/App.tsx index 799fe7b3..cd62abc5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -46,6 +46,7 @@ import { TasksSettings, UsersSettings, } from "@/pages/settings"; +import { WantToRead } from "@/pages/WantToRead"; import { navigationService } from "@/services/navigation"; import { useAuthStore } from "@/store/authStore"; import { useBulkSelectionStore } from "@/store/bulkSelectionStore"; @@ -202,6 +203,17 @@ function App() { } /> + + + + + + } + /> + => { + const query = sort === "oldest" ? "?sort=added_at:asc" : ""; + const response = await api.get( + `/want-to-read${query}`, + ); + return response.data.items; + }, + + /** Add a series to the queue. Idempotent. */ + addSeries: async (seriesId: string): Promise => { + const response = await api.post("/want-to-read", { + seriesId, + }); + return response.data; + }, + + /** Add a book to the queue. Idempotent. */ + addBook: async (bookId: string): Promise => { + const response = await api.post("/want-to-read", { + bookId, + }); + return response.data; + }, + + /** Remove a series from the queue. */ + removeSeries: async (seriesId: string): Promise => { + await api.delete(`/want-to-read/series/${seriesId}`); + }, + + /** Remove a book from the queue. */ + removeBook: async (bookId: string): Promise => { + await api.delete(`/want-to-read/books/${bookId}`); + }, +}; diff --git a/web/src/components/WantToReadButton.test.tsx b/web/src/components/WantToReadButton.test.tsx new file mode 100644 index 00000000..0aec1ca4 --- /dev/null +++ b/web/src/components/WantToReadButton.test.tsx @@ -0,0 +1,52 @@ +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { wantToReadApi } from "@/api/wantToRead"; +import { renderWithProviders, screen, waitFor } from "@/test/utils"; +import { WantToReadButton } from "./WantToReadButton"; + +vi.mock("@/api/wantToRead", () => ({ + wantToReadApi: { + addSeries: vi.fn().mockResolvedValue({}), + addBook: vi.fn().mockResolvedValue({}), + removeSeries: vi.fn().mockResolvedValue(undefined), + removeBook: vi.fn().mockResolvedValue(undefined), + }, +})); + +describe("WantToReadButton", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("adds a series when it isn't in the queue yet", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click( + screen.getByRole("button", { name: /add to want to read/i }), + ); + + await waitFor(() => + expect(wantToReadApi.addSeries).toHaveBeenCalledWith("series-1"), + ); + expect(wantToReadApi.removeSeries).not.toHaveBeenCalled(); + }); + + it("removes a book when it is already in the queue", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click( + screen.getByRole("button", { name: /remove from want to read/i }), + ); + + await waitFor(() => + expect(wantToReadApi.removeBook).toHaveBeenCalledWith("book-1"), + ); + expect(wantToReadApi.addBook).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/components/WantToReadButton.tsx b/web/src/components/WantToReadButton.tsx new file mode 100644 index 00000000..7d10bbc1 --- /dev/null +++ b/web/src/components/WantToReadButton.tsx @@ -0,0 +1,58 @@ +import { ActionIcon, type MantineSize, Tooltip } from "@mantine/core"; +import { IconBookmark, IconBookmarkFilled } from "@tabler/icons-react"; +import { + useAddToWantToRead, + useRemoveFromWantToRead, +} from "@/hooks/useWantToRead"; + +interface WantToReadButtonProps { + itemType: "series" | "book"; + id: string; + /** Current queue membership (from the series/book DTO). */ + wantToRead: boolean | null | undefined; + size?: MantineSize | number; +} + +/** + * Bookmark-style toggle that adds/removes a series or book from the user's + * want-to-read queue. State is driven by the `wantToRead` prop; after a toggle + * the owning detail query is invalidated so the prop refreshes. + */ +export function WantToReadButton({ + itemType, + id, + wantToRead, + size = "lg", +}: WantToReadButtonProps) { + const add = useAddToWantToRead(); + const remove = useRemoveFromWantToRead(); + + const active = Boolean(wantToRead); + const pending = add.isPending || remove.isPending; + const label = active ? "Remove from Want to Read" : "Add to Want to Read"; + + const toggle = () => { + if (pending) return; + if (active) { + remove.mutate({ itemType, id }); + } else { + add.mutate({ itemType, id }); + } + }; + + return ( + + + {active ? : } + + + ); +} diff --git a/web/src/components/layout/Sidebar.tsx b/web/src/components/layout/Sidebar.tsx index 802ce558..9f82907c 100644 --- a/web/src/components/layout/Sidebar.tsx +++ b/web/src/components/layout/Sidebar.tsx @@ -15,6 +15,7 @@ import { useMediaQuery } from "@mantine/hooks"; import { notifications } from "@mantine/notifications"; import { IconAlertTriangle, + IconBookmark, IconBooks, IconBrush, IconChartBar, @@ -396,6 +397,14 @@ export function Sidebar({ onNavigate }: SidebarProps = {}) { active={currentPath === "/"} onClick={onNavigate} /> + } + active={currentPath === "/want-to-read"} + onClick={onNavigate} + /> {hasRecommendationPlugin && ( ({ + queryKey: [QUEUE_KEY, sort], + queryFn: () => wantToReadApi.list(sort), + }); +} + +/** + * Invalidate the queue plus the changed series/book detail query so its + * `wantToRead` flag refreshes. Query-key invalidation is prefix-based, so + * `["series", id]` matches `["series", id, "full"]` etc. + */ +function useInvalidateAfterChange() { + const queryClient = useQueryClient(); + return ({ itemType, id }: WantToReadTarget) => { + queryClient.invalidateQueries({ queryKey: [QUEUE_KEY] }); + queryClient.invalidateQueries({ + queryKey: [itemType === "series" ? "series" : "books", id], + }); + }; +} + +export function useAddToWantToRead() { + const invalidate = useInvalidateAfterChange(); + return useMutation({ + mutationFn: (target: WantToReadTarget) => + target.itemType === "series" + ? wantToReadApi.addSeries(target.id) + : wantToReadApi.addBook(target.id), + onSuccess: (_data, target) => invalidate(target), + onError: (error: Error) => + notifications.show({ + title: "Failed to add to Want to Read", + message: error.message || "Unknown error", + color: "red", + }), + }); +} + +export function useRemoveFromWantToRead() { + const invalidate = useInvalidateAfterChange(); + return useMutation({ + mutationFn: (target: WantToReadTarget) => + target.itemType === "series" + ? wantToReadApi.removeSeries(target.id) + : wantToReadApi.removeBook(target.id), + onSuccess: (_data, target) => invalidate(target), + onError: (error: Error) => + notifications.show({ + title: "Failed to remove from Want to Read", + message: error.message || "Unknown error", + color: "red", + }), + }); +} diff --git a/web/src/pages/BookDetail.tsx b/web/src/pages/BookDetail.tsx index eebaea2e..b7fccf3f 100644 --- a/web/src/pages/BookDetail.tsx +++ b/web/src/pages/BookDetail.tsx @@ -65,6 +65,7 @@ import { GenreTagChips, } from "@/components/series"; import { BookDetailSkeleton } from "@/components/skeletons"; +import { WantToReadButton } from "@/components/WantToReadButton"; import { useDynamicDocumentTitle } from "@/hooks/useDocumentTitle"; import { usePermissions } from "@/hooks/usePermissions"; import { useShowSkeleton } from "@/lib/motion/useShowSkeleton"; @@ -528,87 +529,95 @@ export function BookDetail() { - - - - - - - - {!isCompleted && ( - } - onClick={() => markAsReadMutation.mutate()} - disabled={markAsReadMutation.isPending} - > - Mark as Read - - )} - {hasProgress && ( - } - onClick={() => markAsUnreadMutation.mutate()} - disabled={markAsUnreadMutation.isPending} - > - Mark as Unread - - )} - {canEditBook && ( - <> - - } - onClick={() => analyzeMutation.mutate()} - disabled={analyzeMutation.isPending} - > - Analyze Book - + + + + + + + + + + {!isCompleted && ( } - onClick={() => generateThumbnailMutation.mutate()} - disabled={generateThumbnailMutation.isPending} + leftSection={} + onClick={() => markAsReadMutation.mutate()} + disabled={markAsReadMutation.isPending} > - Regenerate Thumbnail + Mark as Read - + )} + {hasProgress && ( } - onClick={openEditModal} + leftSection={} + onClick={() => markAsUnreadMutation.mutate()} + disabled={markAsUnreadMutation.isPending} > - Edit Metadata + Mark as Unread - {/* Plugin actions for metadata fetching */} - {pluginActions && pluginActions.actions.length > 0 && ( - <> - - Fetch Metadata - {pluginActions.actions.map((action) => ( - } - onClick={() => handlePluginAction(action)} - > - {action.label} - - ))} - - Auto-Apply Metadata - {pluginActions.actions.map((action) => ( - } - onClick={() => handleAutoMatch(action)} - disabled={autoMatchMutation.isPending} - > - {action.pluginDisplayName} - - ))} - - )} - - )} - - + )} + {canEditBook && ( + <> + + } + onClick={() => analyzeMutation.mutate()} + disabled={analyzeMutation.isPending} + > + Analyze Book + + } + onClick={() => generateThumbnailMutation.mutate()} + disabled={generateThumbnailMutation.isPending} + > + Regenerate Thumbnail + + + } + onClick={openEditModal} + > + Edit Metadata + + {/* Plugin actions for metadata fetching */} + {pluginActions && + pluginActions.actions.length > 0 && ( + <> + + Fetch Metadata + {pluginActions.actions.map((action) => ( + } + onClick={() => handlePluginAction(action)} + > + {action.label} + + ))} + + Auto-Apply Metadata + {pluginActions.actions.map((action) => ( + } + onClick={() => handleAutoMatch(action)} + disabled={autoMatchMutation.isPending} + > + {action.pluginDisplayName} + + ))} + + )} + + )} + + + {/* Subtitle */} diff --git a/web/src/pages/SeriesDetail.tsx b/web/src/pages/SeriesDetail.tsx index 0d5f1e7c..11817a31 100644 --- a/web/src/pages/SeriesDetail.tsx +++ b/web/src/pages/SeriesDetail.tsx @@ -71,6 +71,7 @@ import { } from "@/components/series"; import { formatSeriesCounts } from "@/components/series/seriesCounts"; import { SeriesDetailSkeleton } from "@/components/skeletons"; +import { WantToReadButton } from "@/components/WantToReadButton"; import { useDynamicDocumentTitle } from "@/hooks/useDocumentTitle"; import { usePermissions } from "@/hooks/usePermissions"; import { useReleaseTrackingApplicability } from "@/hooks/useReleaseTrackingApplicability"; @@ -634,153 +635,165 @@ export function SeriesDetail() { - - - - - - - - {hasUnread && ( - } - onClick={() => markAsReadMutation.mutate()} - disabled={markAsReadMutation.isPending} - > - Mark as Read - - )} - {hasRead && ( - } - onClick={() => markAsUnreadMutation.mutate()} - disabled={markAsUnreadMutation.isPending} - > - Mark as Unread - - )} - {canEditSeries && ( - <> - - } - onClick={() => analyzeMutation.mutate()} - disabled={analyzeMutation.isPending} - > - Analyze All Books - - } - onClick={() => analyzeUnanalyzedMutation.mutate()} - disabled={analyzeUnanalyzedMutation.isPending} - > - Analyze Unanalyzed Books - - } - onClick={() => renumberMutation.mutate()} - disabled={renumberMutation.isPending} - > - Renumber Books - - - Book Thumbnails - } - onClick={() => - generateMissingBookThumbnailsMutation.mutate() - } - disabled={ - generateMissingBookThumbnailsMutation.isPending - } - > - Generate Missing - - } - onClick={() => - regenerateBookThumbnailsMutation.mutate() - } - disabled={regenerateBookThumbnailsMutation.isPending} - > - Regenerate All - - - Series Thumbnail - } - onClick={() => - generateSeriesThumbnailIfMissingMutation.mutate() - } - disabled={ - generateSeriesThumbnailIfMissingMutation.isPending - } - > - Generate If Missing - - } - onClick={() => - regenerateSeriesThumbnailMutation.mutate() - } - disabled={regenerateSeriesThumbnailMutation.isPending} - > - Regenerate - - - } - onClick={openEditModal} - > - Edit Metadata - + + + + + + + + + + {hasUnread && ( } - onClick={() => reprocessTitleMutation.mutate()} - disabled={reprocessTitleMutation.isPending} + leftSection={} + onClick={() => markAsReadMutation.mutate()} + disabled={markAsReadMutation.isPending} > - Reprocess Title + Mark as Read + )} + {hasRead && ( } - color="red" - onClick={() => setResetConfirmOpened(true)} - disabled={resetMetadataMutation.isPending} + leftSection={} + onClick={() => markAsUnreadMutation.mutate()} + disabled={markAsUnreadMutation.isPending} > - Reset Metadata + Mark as Unread - {/* Plugin actions for metadata fetching */} - {pluginActions && pluginActions.actions.length > 0 && ( - <> - - Fetch Metadata - {pluginActions.actions.map((action) => ( - } - onClick={() => handlePluginAction(action)} - > - {action.label} - - ))} - - Auto-Apply Metadata - {pluginActions.actions.map((action) => ( - } - onClick={() => handleAutoMatch(action)} - disabled={autoMatchMutation.isPending} - > - {action.pluginDisplayName} - - ))} - - )} - - )} - - + )} + {canEditSeries && ( + <> + + } + onClick={() => analyzeMutation.mutate()} + disabled={analyzeMutation.isPending} + > + Analyze All Books + + } + onClick={() => analyzeUnanalyzedMutation.mutate()} + disabled={analyzeUnanalyzedMutation.isPending} + > + Analyze Unanalyzed Books + + } + onClick={() => renumberMutation.mutate()} + disabled={renumberMutation.isPending} + > + Renumber Books + + + Book Thumbnails + } + onClick={() => + generateMissingBookThumbnailsMutation.mutate() + } + disabled={ + generateMissingBookThumbnailsMutation.isPending + } + > + Generate Missing + + } + onClick={() => + regenerateBookThumbnailsMutation.mutate() + } + disabled={ + regenerateBookThumbnailsMutation.isPending + } + > + Regenerate All + + + Series Thumbnail + } + onClick={() => + generateSeriesThumbnailIfMissingMutation.mutate() + } + disabled={ + generateSeriesThumbnailIfMissingMutation.isPending + } + > + Generate If Missing + + } + onClick={() => + regenerateSeriesThumbnailMutation.mutate() + } + disabled={ + regenerateSeriesThumbnailMutation.isPending + } + > + Regenerate + + + } + onClick={openEditModal} + > + Edit Metadata + + } + onClick={() => reprocessTitleMutation.mutate()} + disabled={reprocessTitleMutation.isPending} + > + Reprocess Title + + } + color="red" + onClick={() => setResetConfirmOpened(true)} + disabled={resetMetadataMutation.isPending} + > + Reset Metadata + + {/* Plugin actions for metadata fetching */} + {pluginActions && + pluginActions.actions.length > 0 && ( + <> + + Fetch Metadata + {pluginActions.actions.map((action) => ( + } + onClick={() => handlePluginAction(action)} + > + {action.label} + + ))} + + Auto-Apply Metadata + {pluginActions.actions.map((action) => ( + } + onClick={() => handleAutoMatch(action)} + disabled={autoMatchMutation.isPending} + > + {action.pluginDisplayName} + + ))} + + )} + + )} + + + {/* Book count */} diff --git a/web/src/pages/WantToRead.tsx b/web/src/pages/WantToRead.tsx new file mode 100644 index 00000000..08ea82f5 --- /dev/null +++ b/web/src/pages/WantToRead.tsx @@ -0,0 +1,140 @@ +import { + Button, + Center, + Container, + Group, + SegmentedControl, + SimpleGrid, + Skeleton, + Stack, + Text, + Title, +} from "@mantine/core"; +import { IconBookmark, IconX } from "@tabler/icons-react"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { booksApi } from "@/api/books"; +import { seriesApi } from "@/api/series"; +import type { WantToReadEntry, WantToReadSort } from "@/api/wantToRead"; +import { MediaCard } from "@/components/library/MediaCard"; +import { + useRemoveFromWantToRead, + useWantToReadQueue, + type WantToReadTarget, +} from "@/hooks/useWantToRead"; + +interface QueueItemProps { + entry: WantToReadEntry; + onRemove: (target: WantToReadTarget) => void; + removing: boolean; +} + +/** + * Renders one queue entry. The queue only carries IDs, so we fetch the series + * or book on demand (React Query caches/dedupes) and reuse the standard + * MediaCard. Entries whose target is gone (deleted / inaccessible) render + * nothing. + */ +function QueueItem({ entry, onRemove, removing }: QueueItemProps) { + const isSeries = entry.itemType === "series"; + const id = (isSeries ? entry.seriesId : entry.bookId) ?? ""; + + const seriesQuery = useQuery({ + queryKey: ["series", id], + queryFn: () => seriesApi.getById(id), + enabled: isSeries && Boolean(id), + }); + const bookQuery = useQuery({ + queryKey: ["books", id], + queryFn: () => booksApi.getById(id), + enabled: !isSeries && Boolean(id), + }); + + const isLoading = isSeries ? seriesQuery.isLoading : bookQuery.isLoading; + const data = isSeries ? seriesQuery.data : bookQuery.data; + + if (isLoading) { + return ; + } + if (!data) { + return null; + } + + return ( + + + + + ); +} + +export function WantToRead() { + const [sort, setSort] = useState("newest"); + const { data: entries, isLoading } = useWantToReadQueue(sort); + const removeMutation = useRemoveFromWantToRead(); + const removingId = removeMutation.isPending + ? removeMutation.variables?.id + : undefined; + + return ( + + + + + Want to Read + + setSort(value as WantToReadSort)} + data={[ + { label: "Newest", value: "newest" }, + { label: "Oldest", value: "oldest" }, + ]} + aria-label="Sort order" + /> + + + {isLoading ? ( + + {Array.from({ length: 6 }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholders + + ))} + + ) : !entries || entries.length === 0 ? ( +
+ + + Your Want to Read queue is empty. + + Add a series or book from its detail page to read it later. + + +
+ ) : ( + + {entries.map((entry) => ( + removeMutation.mutate(target)} + removing={ + removingId === + (entry.itemType === "series" ? entry.seriesId : entry.bookId) + } + /> + ))} + + )} +
+ ); +} diff --git a/web/src/types/api.generated.ts b/web/src/types/api.generated.ts index 45de6d97..c939bbf0 100644 --- a/web/src/types/api.generated.ts +++ b/web/src/types/api.generated.ts @@ -11202,6 +11202,11 @@ export interface components { * @example 2024-01-15T10:30:00Z */ updatedAt: string; + /** + * @description Whether the requesting user has this book in their want-to-read queue. + * @example false + */ + wantToRead?: boolean | null; }; /** @description Full series metadata response including all related data */ FullSeriesMetadataResponse: { @@ -11420,6 +11425,11 @@ export interface components { * @example 14 */ volumesOwned?: number | null; + /** + * @description Whether the requesting user has this series in their want-to-read queue. + * @example false + */ + wantToRead?: boolean | null; }; /** @description Request body for batch book thumbnail generation */ GenerateBookThumbnailsRequest: { From 16b16e8b2ea36b9d3d4e2b3a6b2a20b77fe164aa Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Mon, 15 Jun 2026 17:17:00 -0700 Subject: [PATCH 05/13] feat(api): add collections endpoints Expose shared, ordered collections of series over /api/v1/collections: - List, create, get, rename / toggle ordering, and delete collections - Manage members: add one or many series, remove, and set manual order - Browse members as full series DTOs, filtered by the requesting user's sharing-tag visibility, with the visible count on each collection - A thumbnail endpoint that redirects to the first visible member's cover, and a reverse lookup of the collections containing a series Reads are available to every role; create/modify require collections write and delete requires collections delete. Register the paths and schemas in the OpenAPI doc and regenerate the committed spec and types. Covered by integration tests including the permission matrix. --- crates/codex-api/src/docs.rs | 21 + .../codex-api/src/routes/v1/dto/collection.rs | 85 +++ crates/codex-api/src/routes/v1/dto/mod.rs | 2 + .../src/routes/v1/handlers/collections.rs | 464 +++++++++++++ .../codex-api/src/routes/v1/handlers/mod.rs | 2 + .../src/routes/v1/handlers/series.rs | 2 +- .../src/routes/v1/routes/collections.rs | 56 ++ crates/codex-api/src/routes/v1/routes/mod.rs | 2 + docs/api/openapi.json | 637 ++++++++++++++++++ tests/api/collections.rs | 338 ++++++++++ tests/api/mod.rs | 1 + web/openapi.json | 637 ++++++++++++++++++ web/src/types/api.generated.ts | 576 ++++++++++++++++ 13 files changed, 2822 insertions(+), 1 deletion(-) create mode 100644 crates/codex-api/src/routes/v1/dto/collection.rs create mode 100644 crates/codex-api/src/routes/v1/handlers/collections.rs create mode 100644 crates/codex-api/src/routes/v1/routes/collections.rs create mode 100644 tests/api/collections.rs diff --git a/crates/codex-api/src/docs.rs b/crates/codex-api/src/docs.rs index cae2bf66..41546be4 100644 --- a/crates/codex-api/src/docs.rs +++ b/crates/codex-api/src/docs.rs @@ -362,6 +362,19 @@ The following paths are exempt from rate limiting: v1::handlers::remove_want_to_read_series, v1::handlers::remove_want_to_read_book, + // Collections endpoints + v1::handlers::list_collections, + v1::handlers::create_collection, + v1::handlers::get_collection, + v1::handlers::update_collection, + v1::handlers::delete_collection, + v1::handlers::get_collection_series, + v1::handlers::add_collection_series, + v1::handlers::remove_collection_series, + v1::handlers::reorder_collection_series, + v1::handlers::get_collection_thumbnail, + v1::handlers::get_series_collections, + // Bulk operations endpoints v1::handlers::bulk_mark_books_as_read, v1::handlers::bulk_mark_books_as_unread, @@ -929,6 +942,14 @@ The following paths are exempt from rate limiting: v1::dto::AddWantToReadRequest, v1::dto::WantToReadItemType, + // Collections DTOs + v1::dto::CollectionDto, + v1::dto::CollectionListResponse, + v1::dto::CreateCollectionRequest, + v1::dto::UpdateCollectionRequest, + v1::dto::AddSeriesToCollectionRequest, + v1::dto::ReorderCollectionSeriesRequest, + // Bulk operations DTOs v1::dto::BulkBooksRequest, v1::dto::BulkAnalyzeBooksRequest, diff --git a/crates/codex-api/src/routes/v1/dto/collection.rs b/crates/codex-api/src/routes/v1/dto/collection.rs new file mode 100644 index 00000000..9bda159b --- /dev/null +++ b/crates/codex-api/src/routes/v1/dto/collection.rs @@ -0,0 +1,85 @@ +//! DTOs for collections (shared, ordered groupings of series). + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +/// A collection of series. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CollectionDto { + #[schema(example = "550e8400-e29b-41d4-a716-446655440000")] + pub id: Uuid, + #[schema(example = "Batman")] + pub name: String, + /// When true, members are kept in manual order; otherwise sorted by title. + #[schema(example = false)] + pub ordered: bool, + /// Number of member series visible to the requesting user. + #[schema(example = 12)] + pub series_count: u64, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl CollectionDto { + pub fn from_model(model: codex_db::entities::collections::Model, series_count: u64) -> Self { + Self { + id: model.id, + name: model.name, + ordered: model.ordered, + series_count, + created_at: model.created_at, + updated_at: model.updated_at, + } + } +} + +/// List of collections. +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CollectionListResponse { + pub items: Vec, + #[schema(example = 3)] + pub total: usize, +} + +/// Request to create a collection. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateCollectionRequest { + #[schema(example = "Batman")] + pub name: String, + /// Defaults to `false` (members sorted by title). + #[serde(default)] + #[schema(example = false)] + pub ordered: bool, +} + +/// Request to update a collection. Absent fields are left unchanged. +#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateCollectionRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ordered: Option, +} + +/// Request to add one or more series to a collection. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AddSeriesToCollectionRequest { + #[schema(example = json!(["550e8400-e29b-41d4-a716-446655440001"]))] + pub series_ids: Vec, +} + +/// Request to set the manual order of a collection's series. IDs not currently +/// members are ignored; omitted members keep their existing position. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ReorderCollectionSeriesRequest { + #[schema(example = json!(["550e8400-e29b-41d4-a716-446655440002", "550e8400-e29b-41d4-a716-446655440001"]))] + pub series_ids: Vec, +} diff --git a/crates/codex-api/src/routes/v1/dto/mod.rs b/crates/codex-api/src/routes/v1/dto/mod.rs index 1c0c796a..bc029ead 100644 --- a/crates/codex-api/src/routes/v1/dto/mod.rs +++ b/crates/codex-api/src/routes/v1/dto/mod.rs @@ -8,6 +8,7 @@ pub mod auth; pub mod book; pub mod bulk_metadata; pub mod cleanup; +pub mod collection; pub mod common; pub mod duplicates; pub mod filter; @@ -46,6 +47,7 @@ pub use book::*; #[allow(unused_imports)] pub use bulk_metadata::*; pub use cleanup::*; +pub use collection::*; pub use common::*; pub use duplicates::*; pub use filter::*; diff --git a/crates/codex-api/src/routes/v1/handlers/collections.rs b/crates/codex-api/src/routes/v1/handlers/collections.rs new file mode 100644 index 00000000..8a1eeb09 --- /dev/null +++ b/crates/codex-api/src/routes/v1/handlers/collections.rs @@ -0,0 +1,464 @@ +//! Handlers for collections (shared, ordered groupings of series). +//! +//! Reads require `CollectionsRead` (granted to every role); create/modify +//! require `CollectionsWrite`; delete requires `CollectionsDelete` (write/delete +//! are in the Maintainer bundle). Member lists and counts are filtered through +//! the requesting user's sharing-tag visibility. + +use super::super::dto::{ + AddSeriesToCollectionRequest, CollectionDto, CollectionListResponse, CreateCollectionRequest, + ReorderCollectionSeriesRequest, SeriesDto, UpdateCollectionRequest, +}; +use crate::require_permission; +use crate::{ + error::ApiError, + extractors::{AuthContext, AuthState, ContentFilter, FlexibleAuthContext}, + permissions::Permission, +}; +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, + response::Redirect, +}; +use codex_db::repositories::{ + CollectionRepository, SeriesRepository, visibility::SeriesVisibility, +}; +use std::sync::Arc; +use utoipa::OpenApi; +use uuid::Uuid; + +#[derive(OpenApi)] +#[openapi( + paths( + list_collections, + create_collection, + get_collection, + update_collection, + delete_collection, + get_collection_series, + add_collection_series, + remove_collection_series, + reorder_collection_series, + get_collection_thumbnail, + get_series_collections, + ), + components(schemas( + CollectionDto, + CollectionListResponse, + CreateCollectionRequest, + UpdateCollectionRequest, + AddSeriesToCollectionRequest, + ReorderCollectionSeriesRequest, + )), + tags( + (name = "Collections", description = "Shared, ordered groupings of series") + ) +)] +#[allow(dead_code)] // OpenAPI documentation struct - referenced by utoipa derive macros +pub struct CollectionsApi; + +fn internal(context: &str) -> impl Fn(E) -> ApiError + '_ { + move |e| ApiError::Internal(format!("{context}: {e}")) +} + +/// Build a CollectionDto with the requesting user's visible member count. +async fn collection_dto( + db: &sea_orm::DatabaseConnection, + model: codex_db::entities::collections::Model, + vis: Option<&SeriesVisibility>, +) -> Result { + let count = CollectionRepository::count_series(db, model.id, vis) + .await + .map_err(internal("Failed to count collection series"))?; + Ok(CollectionDto::from_model(model, count)) +} + +async fn user_visibility( + state: &AuthState, + user_id: Uuid, +) -> Result, ApiError> { + let filter = ContentFilter::for_user(&state.db, user_id) + .await + .map_err(internal("Failed to load content filter"))?; + Ok(filter.to_visibility()) +} + +/// List all collections. +#[utoipa::path( + get, + path = "/api/v1/collections", + responses( + (status = 200, description = "Collections", body = CollectionListResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Collections" +)] +pub async fn list_collections( + State(state): State>, + auth: AuthContext, +) -> Result, ApiError> { + require_permission!(auth, Permission::CollectionsRead)?; + let vis = user_visibility(&state, auth.user_id).await?; + + let collections = CollectionRepository::list_all(&state.db) + .await + .map_err(internal("Failed to list collections"))?; + + let mut items = Vec::with_capacity(collections.len()); + for model in collections { + items.push(collection_dto(&state.db, model, vis.as_ref()).await?); + } + let total = items.len(); + Ok(Json(CollectionListResponse { items, total })) +} + +/// Create a collection. +#[utoipa::path( + post, + path = "/api/v1/collections", + request_body = CreateCollectionRequest, + responses( + (status = 201, description = "Created", body = CollectionDto), + (status = 400, description = "Invalid name"), + (status = 403, description = "Forbidden"), + (status = 409, description = "A collection with that name already exists"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Collections" +)] +pub async fn create_collection( + State(state): State>, + auth: AuthContext, + Json(request): Json, +) -> Result<(StatusCode, Json), ApiError> { + require_permission!(auth, Permission::CollectionsWrite)?; + + let name = request.name.trim(); + if name.is_empty() { + return Err(ApiError::BadRequest( + "Collection name cannot be empty".to_string(), + )); + } + if CollectionRepository::get_by_name(&state.db, name) + .await + .map_err(internal("Failed to check collection name"))? + .is_some() + { + return Err(ApiError::Conflict(format!( + "A collection named '{name}' already exists" + ))); + } + + let model = CollectionRepository::create(&state.db, name, request.ordered) + .await + .map_err(internal("Failed to create collection"))?; + Ok(( + StatusCode::CREATED, + Json(CollectionDto::from_model(model, 0)), + )) +} + +/// Get a collection. +#[utoipa::path( + get, + path = "/api/v1/collections/{collection_id}", + responses( + (status = 200, description = "Collection", body = CollectionDto), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Collections" +)] +pub async fn get_collection( + State(state): State>, + auth: AuthContext, + Path(collection_id): Path, +) -> Result, ApiError> { + require_permission!(auth, Permission::CollectionsRead)?; + let model = CollectionRepository::get_by_id(&state.db, collection_id) + .await + .map_err(internal("Failed to fetch collection"))? + .ok_or_else(|| ApiError::NotFound("Collection not found".to_string()))?; + let vis = user_visibility(&state, auth.user_id).await?; + Ok(Json(collection_dto(&state.db, model, vis.as_ref()).await?)) +} + +/// Update a collection (rename / toggle ordered). +#[utoipa::path( + patch, + path = "/api/v1/collections/{collection_id}", + request_body = UpdateCollectionRequest, + responses( + (status = 200, description = "Updated", body = CollectionDto), + (status = 400, description = "Invalid name"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 409, description = "Name already in use"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Collections" +)] +pub async fn update_collection( + State(state): State>, + auth: AuthContext, + Path(collection_id): Path, + Json(request): Json, +) -> Result, ApiError> { + require_permission!(auth, Permission::CollectionsWrite)?; + + if let Some(ref new_name) = request.name { + let trimmed = new_name.trim(); + if trimmed.is_empty() { + return Err(ApiError::BadRequest( + "Collection name cannot be empty".to_string(), + )); + } + if let Some(existing) = CollectionRepository::get_by_name(&state.db, trimmed) + .await + .map_err(internal("Failed to check collection name"))? + && existing.id != collection_id + { + return Err(ApiError::Conflict(format!( + "A collection named '{trimmed}' already exists" + ))); + } + } + + let model = CollectionRepository::update( + &state.db, + collection_id, + request.name.as_deref().map(str::trim), + request.ordered, + ) + .await + .map_err(internal("Failed to update collection"))? + .ok_or_else(|| ApiError::NotFound("Collection not found".to_string()))?; + + let vis = user_visibility(&state, auth.user_id).await?; + Ok(Json(collection_dto(&state.db, model, vis.as_ref()).await?)) +} + +/// Delete a collection. +#[utoipa::path( + delete, + path = "/api/v1/collections/{collection_id}", + responses( + (status = 204, description = "Deleted"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Collections" +)] +pub async fn delete_collection( + State(state): State>, + auth: AuthContext, + Path(collection_id): Path, +) -> Result { + require_permission!(auth, Permission::CollectionsDelete)?; + let deleted = CollectionRepository::delete(&state.db, collection_id) + .await + .map_err(internal("Failed to delete collection"))?; + if !deleted { + return Err(ApiError::NotFound("Collection not found".to_string())); + } + Ok(StatusCode::NO_CONTENT) +} + +/// Get the series in a collection (visibility-filtered, in stored order). +#[utoipa::path( + get, + path = "/api/v1/collections/{collection_id}/series", + responses( + (status = 200, description = "Member series", body = [SeriesDto]), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Collections" +)] +pub async fn get_collection_series( + State(state): State>, + auth: AuthContext, + Path(collection_id): Path, +) -> Result>, ApiError> { + require_permission!(auth, Permission::CollectionsRead)?; + ensure_collection_exists(&state, collection_id).await?; + + let vis = user_visibility(&state, auth.user_id).await?; + let members = CollectionRepository::get_series(&state.db, collection_id, vis.as_ref()) + .await + .map_err(internal("Failed to fetch collection series"))?; + + let dtos = + super::series::series_to_dtos_batched(&state.db, members, Some(auth.user_id)).await?; + Ok(Json(dtos)) +} + +/// Add one or more series to a collection. +#[utoipa::path( + post, + path = "/api/v1/collections/{collection_id}/series", + request_body = AddSeriesToCollectionRequest, + responses( + (status = 200, description = "Updated collection", body = CollectionDto), + (status = 403, description = "Forbidden"), + (status = 404, description = "Collection or series not found"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Collections" +)] +pub async fn add_collection_series( + State(state): State>, + auth: AuthContext, + Path(collection_id): Path, + Json(request): Json, +) -> Result, ApiError> { + require_permission!(auth, Permission::CollectionsWrite)?; + let model = CollectionRepository::get_by_id(&state.db, collection_id) + .await + .map_err(internal("Failed to fetch collection"))? + .ok_or_else(|| ApiError::NotFound("Collection not found".to_string()))?; + + for series_id in &request.series_ids { + if SeriesRepository::get_by_id(&state.db, *series_id) + .await + .map_err(internal("Failed to look up series"))? + .is_none() + { + return Err(ApiError::NotFound(format!("Series {series_id} not found"))); + } + CollectionRepository::add_series(&state.db, collection_id, *series_id) + .await + .map_err(internal("Failed to add series to collection"))?; + } + + let vis = user_visibility(&state, auth.user_id).await?; + Ok(Json(collection_dto(&state.db, model, vis.as_ref()).await?)) +} + +/// Remove a series from a collection. +#[utoipa::path( + delete, + path = "/api/v1/collections/{collection_id}/series/{series_id}", + responses( + (status = 204, description = "Removed (or was not a member)"), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Collections" +)] +pub async fn remove_collection_series( + State(state): State>, + auth: AuthContext, + Path((collection_id, series_id)): Path<(Uuid, Uuid)>, +) -> Result { + require_permission!(auth, Permission::CollectionsWrite)?; + CollectionRepository::remove_series(&state.db, collection_id, series_id) + .await + .map_err(internal("Failed to remove series from collection"))?; + Ok(StatusCode::NO_CONTENT) +} + +/// Set the manual order of a collection's series. +#[utoipa::path( + put, + path = "/api/v1/collections/{collection_id}/series", + request_body = ReorderCollectionSeriesRequest, + responses( + (status = 204, description = "Reordered"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Collections" +)] +pub async fn reorder_collection_series( + State(state): State>, + auth: AuthContext, + Path(collection_id): Path, + Json(request): Json, +) -> Result { + require_permission!(auth, Permission::CollectionsWrite)?; + ensure_collection_exists(&state, collection_id).await?; + CollectionRepository::reorder(&state.db, collection_id, &request.series_ids) + .await + .map_err(internal("Failed to reorder collection series"))?; + Ok(StatusCode::NO_CONTENT) +} + +/// Get a collection's thumbnail (the first visible member series' cover). +#[utoipa::path( + get, + path = "/api/v1/collections/{collection_id}/thumbnail", + responses( + (status = 307, description = "Redirect to the first member series thumbnail"), + (status = 404, description = "No visible member series"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Collections" +)] +pub async fn get_collection_thumbnail( + State(state): State>, + FlexibleAuthContext(auth): FlexibleAuthContext, + Path(collection_id): Path, +) -> Result { + auth.require_permission(&Permission::CollectionsRead)?; + let vis = user_visibility(&state, auth.user_id).await?; + let members = CollectionRepository::get_series(&state.db, collection_id, vis.as_ref()) + .await + .map_err(internal("Failed to fetch collection series"))?; + let first = members + .first() + .ok_or_else(|| ApiError::NotFound("Collection has no visible series".to_string()))?; + Ok(Redirect::temporary(&format!( + "/api/v1/series/{}/thumbnail", + first.id + ))) +} + +/// List the collections that contain a given series. +#[utoipa::path( + get, + path = "/api/v1/series/{series_id}/collections", + responses( + (status = 200, description = "Collections containing the series", body = CollectionListResponse), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Collections" +)] +pub async fn get_series_collections( + State(state): State>, + auth: AuthContext, + Path(series_id): Path, +) -> Result, ApiError> { + require_permission!(auth, Permission::CollectionsRead)?; + let vis = user_visibility(&state, auth.user_id).await?; + + let collections = CollectionRepository::get_collections_for_series(&state.db, series_id) + .await + .map_err(internal("Failed to fetch collections for series"))?; + + let mut items = Vec::with_capacity(collections.len()); + for model in collections { + items.push(collection_dto(&state.db, model, vis.as_ref()).await?); + } + let total = items.len(); + Ok(Json(CollectionListResponse { items, total })) +} + +async fn ensure_collection_exists(state: &AuthState, collection_id: Uuid) -> Result<(), ApiError> { + if CollectionRepository::get_by_id(&state.db, collection_id) + .await + .map_err(internal("Failed to fetch collection"))? + .is_none() + { + return Err(ApiError::NotFound("Collection not found".to_string())); + } + Ok(()) +} diff --git a/crates/codex-api/src/routes/v1/handlers/mod.rs b/crates/codex-api/src/routes/v1/handlers/mod.rs index 6cda0b2c..30740ba2 100644 --- a/crates/codex-api/src/routes/v1/handlers/mod.rs +++ b/crates/codex-api/src/routes/v1/handlers/mod.rs @@ -46,6 +46,7 @@ pub mod books; pub mod bulk; pub mod bulk_metadata; pub mod cleanup; +pub mod collections; pub mod duplicates; pub mod events; pub mod filesystem; @@ -83,6 +84,7 @@ pub use auth::*; pub use books::*; pub use bulk::*; pub use bulk_metadata::*; +pub use collections::*; pub use duplicates::*; pub use events::*; pub use filesystem::*; diff --git a/crates/codex-api/src/routes/v1/handlers/series.rs b/crates/codex-api/src/routes/v1/handlers/series.rs index a686153a..f4fd5711 100644 --- a/crates/codex-api/src/routes/v1/handlers/series.rs +++ b/crates/codex-api/src/routes/v1/handlers/series.rs @@ -252,7 +252,7 @@ async fn series_to_dto( /// model using a single batched query per related table, rather than the /// per-row fan-out the single-row helper does. Order of `series_list` is /// preserved in the output. -async fn series_to_dtos_batched( +pub(crate) async fn series_to_dtos_batched( db: &DatabaseConnection, series_list: Vec, user_id: Option, diff --git a/crates/codex-api/src/routes/v1/routes/collections.rs b/crates/codex-api/src/routes/v1/routes/collections.rs new file mode 100644 index 00000000..37f1f73d --- /dev/null +++ b/crates/codex-api/src/routes/v1/routes/collections.rs @@ -0,0 +1,56 @@ +//! Collection routes +//! +//! Shared, ordered groupings of series. Reads are available to all roles; +//! create/modify/delete are gated by the collection permissions. + +use super::super::handlers; +use crate::extractors::AppState; +use axum::{ + Router, + routing::{delete, get, patch, post, put}, +}; +use std::sync::Arc; + +/// Create collection routes. +pub fn routes(_state: Arc) -> Router> { + Router::new() + .route("/collections", get(handlers::list_collections)) + .route("/collections", post(handlers::create_collection)) + .route( + "/collections/{collection_id}", + get(handlers::get_collection), + ) + .route( + "/collections/{collection_id}", + patch(handlers::update_collection), + ) + .route( + "/collections/{collection_id}", + delete(handlers::delete_collection), + ) + .route( + "/collections/{collection_id}/series", + get(handlers::get_collection_series), + ) + .route( + "/collections/{collection_id}/series", + post(handlers::add_collection_series), + ) + .route( + "/collections/{collection_id}/series", + put(handlers::reorder_collection_series), + ) + .route( + "/collections/{collection_id}/series/{series_id}", + delete(handlers::remove_collection_series), + ) + .route( + "/collections/{collection_id}/thumbnail", + get(handlers::get_collection_thumbnail), + ) + // Reverse lookup: collections that contain a given series. + .route( + "/series/{series_id}/collections", + get(handlers::get_series_collections), + ) +} diff --git a/crates/codex-api/src/routes/v1/routes/mod.rs b/crates/codex-api/src/routes/v1/routes/mod.rs index 01a49dc5..5e88579f 100644 --- a/crates/codex-api/src/routes/v1/routes/mod.rs +++ b/crates/codex-api/src/routes/v1/routes/mod.rs @@ -6,6 +6,7 @@ mod admin; mod auth; mod books; +mod collections; mod libraries; mod misc; mod observability; @@ -47,6 +48,7 @@ pub fn create_router(state: Arc) -> Router { .merge(releases::routes(state.clone())) .merge(observability::routes(state.clone())) .merge(want_to_read::routes(state.clone())) + .merge(collections::routes(state.clone())) // Apply state to all routes .with_state(state) } diff --git a/docs/api/openapi.json b/docs/api/openapi.json index d14beb17..eb89549a 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -5510,6 +5510,461 @@ ] } }, + "/api/v1/collections": { + "get": { + "tags": [ + "Collections" + ], + "summary": "List all collections.", + "operationId": "list_collections", + "responses": { + "200": { + "description": "Collections", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionListResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "post": { + "tags": [ + "Collections" + ], + "summary": "Create a collection.", + "operationId": "create_collection", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCollectionRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionDto" + } + } + } + }, + "400": { + "description": "Invalid name" + }, + "403": { + "description": "Forbidden" + }, + "409": { + "description": "A collection with that name already exists" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/collections/{collection_id}": { + "get": { + "tags": [ + "Collections" + ], + "summary": "Get a collection.", + "operationId": "get_collection", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Collection", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionDto" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "delete": { + "tags": [ + "Collections" + ], + "summary": "Delete a collection.", + "operationId": "delete_collection", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Deleted" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "patch": { + "tags": [ + "Collections" + ], + "summary": "Update a collection (rename / toggle ordered).", + "operationId": "update_collection", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCollectionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionDto" + } + } + } + }, + "400": { + "description": "Invalid name" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "409": { + "description": "Name already in use" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/collections/{collection_id}/series": { + "get": { + "tags": [ + "Collections" + ], + "summary": "Get the series in a collection (visibility-filtered, in stored order).", + "operationId": "get_collection_series", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Member series", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesDto" + } + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "put": { + "tags": [ + "Collections" + ], + "summary": "Set the manual order of a collection's series.", + "operationId": "reorder_collection_series", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReorderCollectionSeriesRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Reordered" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "post": { + "tags": [ + "Collections" + ], + "summary": "Add one or more series to a collection.", + "operationId": "add_collection_series", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddSeriesToCollectionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Updated collection", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionDto" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Collection or series not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/collections/{collection_id}/series/{series_id}": { + "delete": { + "tags": [ + "Collections" + ], + "summary": "Remove a series from a collection.", + "operationId": "remove_collection_series", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "series_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Removed (or was not a member)" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/collections/{collection_id}/thumbnail": { + "get": { + "tags": [ + "Collections" + ], + "summary": "Get a collection's thumbnail (the first visible member series' cover).", + "operationId": "get_collection_thumbnail", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "307": { + "description": "Redirect to the first member series thumbnail" + }, + "404": { + "description": "No visible member series" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, "/api/v1/duplicates": { "get": { "tags": [ @@ -11423,6 +11878,49 @@ ] } }, + "/api/v1/series/{series_id}/collections": { + "get": { + "tags": [ + "Collections" + ], + "summary": "List the collections that contain a given series.", + "operationId": "get_series_collections", + "parameters": [ + { + "name": "series_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Collections containing the series", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionListResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, "/api/v1/series/{series_id}/cover": { "post": { "tags": [ @@ -19681,6 +20179,25 @@ } } }, + "AddSeriesToCollectionRequest": { + "type": "object", + "description": "Request to add one or more series to a collection.", + "required": [ + "seriesIds" + ], + "properties": { + "seriesIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "example": [ + "550e8400-e29b-41d4-a716-446655440001" + ] + } + } + }, "AddWantToReadRequest": { "type": "object", "description": "Request to add an entry to the queue. Exactly one of `series_id` / `book_id`\nmust be provided.", @@ -24016,6 +24533,70 @@ } } }, + "CollectionDto": { + "type": "object", + "description": "A collection of series.", + "required": [ + "id", + "name", + "ordered", + "seriesCount", + "createdAt", + "updatedAt" + ], + "properties": { + "createdAt": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "name": { + "type": "string", + "example": "Batman" + }, + "ordered": { + "type": "boolean", + "description": "When true, members are kept in manual order; otherwise sorted by title.", + "example": false + }, + "seriesCount": { + "type": "integer", + "format": "int64", + "description": "Number of member series visible to the requesting user.", + "example": 12, + "minimum": 0 + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "CollectionListResponse": { + "type": "object", + "description": "List of collections.", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CollectionDto" + } + }, + "total": { + "type": "integer", + "example": 3, + "minimum": 0 + } + } + }, "ConfigFieldDto": { "type": "object", "description": "Configuration field definition for documenting plugin config options", @@ -24294,6 +24875,24 @@ } } }, + "CreateCollectionRequest": { + "type": "object", + "description": "Request to create a collection.", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "example": "Batman" + }, + "ordered": { + "type": "boolean", + "description": "Defaults to `false` (members sorted by title).", + "example": false + } + } + }, "CreateExternalLinkRequest": { "type": "object", "description": "Request to create or update an external link for a series", @@ -36527,6 +37126,26 @@ } } }, + "ReorderCollectionSeriesRequest": { + "type": "object", + "description": "Request to set the manual order of a collection's series. IDs not currently\nmembers are ignored; omitted members keep their existing position.", + "required": [ + "seriesIds" + ], + "properties": { + "seriesIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "example": [ + "550e8400-e29b-41d4-a716-446655440002", + "550e8400-e29b-41d4-a716-446655440001" + ] + } + } + }, "ReplaceBookMetadataRequest": { "type": "object", "description": "PUT request for full replacement of book metadata\n\nAll metadata fields will be replaced with the values in this request.\nOmitting a field (or setting it to null) will clear that field.", @@ -41711,6 +42330,24 @@ } } }, + "UpdateCollectionRequest": { + "type": "object", + "description": "Request to update a collection. Absent fields are left unchanged.", + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "ordered": { + "type": [ + "boolean", + "null" + ] + } + } + }, "UpdateFilterPresetRequest": { "type": "object", "description": "Request body for updating an existing filter preset.\n\nTreated as a full replacement of the mutable fields. `scope` and `target`\nare immutable since the condition is validated against them on create.", diff --git a/tests/api/collections.rs b/tests/api/collections.rs new file mode 100644 index 00000000..ca436b70 --- /dev/null +++ b/tests/api/collections.rs @@ -0,0 +1,338 @@ +#[path = "../common/mod.rs"] +mod common; + +use codex::api::error::ErrorResponse; +use codex::api::routes::v1::dto::{CollectionDto, CollectionListResponse, SeriesDto}; +use codex::db::ScanningStrategy; +use codex::db::repositories::{LibraryRepository, SeriesRepository, UserRepository}; +use codex::utils::password; +use common::*; +use hyper::StatusCode; + +async fn user_and_token( + db: &sea_orm::DatabaseConnection, + state: &codex::api::extractors::AuthState, + username: &str, + is_admin: bool, +) -> (uuid::Uuid, String) { + let password_hash = password::hash_password("pw123456").unwrap(); + let user = create_test_user( + username, + &format!("{username}@example.com"), + &password_hash, + is_admin, + ); + let created = UserRepository::create(db, &user).await.unwrap(); + let token = state + .jwt_service + .generate_token(created.id, created.username.clone(), created.get_role()) + .unwrap(); + (created.id, token) +} + +/// A reader carrying an explicit `collections-write` custom permission. +async fn reader_with_write_token( + db: &sea_orm::DatabaseConnection, + state: &codex::api::extractors::AuthState, +) -> String { + let password_hash = password::hash_password("pw123456").unwrap(); + let user = create_test_user_with_permissions( + "editor", + "editor@example.com", + &password_hash, + false, + vec![ + "collections-read".to_string(), + "collections-write".to_string(), + ], + ); + let created = UserRepository::create(db, &user).await.unwrap(); + state + .jwt_service + .generate_token(created.id, created.username.clone(), created.get_role()) + .unwrap() +} + +async fn make_series( + db: &sea_orm::DatabaseConnection, + name: &str, +) -> codex::db::entities::series::Model { + let library = LibraryRepository::create(db, "Lib", "/test", ScanningStrategy::Default) + .await + .unwrap(); + SeriesRepository::create(db, library.id, name, None) + .await + .unwrap() +} + +/// Create N series under a single shared library. +async fn make_series_in_library( + db: &sea_orm::DatabaseConnection, + names: &[&str], +) -> Vec { + let library = LibraryRepository::create(db, "Lib", "/test", ScanningStrategy::Default) + .await + .unwrap(); + let mut out = Vec::new(); + for name in names { + out.push( + SeriesRepository::create(db, library.id, name, None) + .await + .unwrap(), + ); + } + out +} + +#[tokio::test] +async fn test_create_get_and_list_collection() { + let (db, _t) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "admin", true).await; + let app = create_test_router(state).await; + + let req = post_json_request_with_auth( + "/api/v1/collections", + &serde_json::json!({ "name": "Batman", "ordered": true }), + &token, + ); + let (status, created): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::CREATED); + let created = created.unwrap(); + assert_eq!(created.name, "Batman"); + assert!(created.ordered); + assert_eq!(created.series_count, 0); + + let req = get_request_with_auth(&format!("/api/v1/collections/{}", created.id), &token); + let (status, fetched): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(fetched.unwrap().id, created.id); + + let req = get_request_with_auth("/api/v1/collections", &token); + let (status, list): (StatusCode, Option) = + make_json_request(app, req).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(list.unwrap().total, 1); +} + +#[tokio::test] +async fn test_create_rejects_empty_and_duplicate_name() { + let (db, _t) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "admin", true).await; + let app = create_test_router(state).await; + + let req = post_json_request_with_auth( + "/api/v1/collections", + &serde_json::json!({ "name": " " }), + &token, + ); + let (status, _): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + + let body = serde_json::json!({ "name": "Marvel" }); + let req = post_json_request_with_auth("/api/v1/collections", &body, &token); + let (status, _): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::CREATED); + + let req = post_json_request_with_auth("/api/v1/collections", &body, &token); + let (status, _): (StatusCode, Option) = make_json_request(app, req).await; + assert_eq!(status, StatusCode::CONFLICT); +} + +#[tokio::test] +async fn test_permission_matrix() { + let (db, _t) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_admin, admin_token) = user_and_token(&db, &state, "admin", true).await; + let (_reader, reader_token) = user_and_token(&db, &state, "reader", false).await; + let editor_token = reader_with_write_token(&db, &state).await; + let app = create_test_router(state).await; + + // Reader can list (CollectionsRead is in the reader bundle). + let req = get_request_with_auth("/api/v1/collections", &reader_token); + let (status, _): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::OK); + + // Reader cannot create (no CollectionsWrite). + let body = serde_json::json!({ "name": "Nope" }); + let req = post_json_request_with_auth("/api/v1/collections", &body, &reader_token); + let (status, _): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + + // A reader with an explicit collections-write permission can create. + let req = post_json_request_with_auth( + "/api/v1/collections", + &serde_json::json!({ "name": "Editor's Pick" }), + &editor_token, + ); + let (status, _): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::CREATED); + + // Admin can create. + let req = post_json_request_with_auth("/api/v1/collections", &body, &admin_token); + let (status, created): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::CREATED); + let created = created.unwrap(); + + // Reader cannot delete (no CollectionsDelete; editor lacks it too). + let req = delete_request_with_auth( + &format!("/api/v1/collections/{}", created.id), + &reader_token, + ); + let (status, _): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + + // Admin can delete. + let req = + delete_request_with_auth(&format!("/api/v1/collections/{}", created.id), &admin_token); + let (status, _): (StatusCode, Option) = make_json_request(app, req).await; + assert_eq!(status, StatusCode::NO_CONTENT); +} + +#[tokio::test] +async fn test_member_management_order_and_count() { + let (db, _t) = setup_test_db().await; + let series = make_series_in_library(&db, &["Alpha", "Bravo", "Charlie"]).await; + + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "admin", true).await; + let app = create_test_router(state).await; + + // Ordered collection so manual order is honored. + let req = post_json_request_with_auth( + "/api/v1/collections", + &serde_json::json!({ "name": "Coll", "ordered": true }), + &token, + ); + let (_s, coll): (StatusCode, Option) = make_json_request(app.clone(), req).await; + let coll_id = coll.unwrap().id; + + // Add all three. + let req = post_json_request_with_auth( + &format!("/api/v1/collections/{coll_id}/series"), + &serde_json::json!({ "seriesIds": series.iter().map(|s| s.id).collect::>() }), + &token, + ); + let (status, updated): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(updated.unwrap().series_count, 3); + + // Members come back in insertion order. + let req = get_request_with_auth(&format!("/api/v1/collections/{coll_id}/series"), &token); + let (status, members): (StatusCode, Option>) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::OK); + let members = members.unwrap(); + assert_eq!(members.len(), 3); + assert_eq!(members[0].id, series[0].id); + assert_eq!(members[2].id, series[2].id); + + // Reorder reversed. + let reversed: Vec<_> = series.iter().rev().map(|s| s.id).collect(); + let req = put_json_request_with_auth( + &format!("/api/v1/collections/{coll_id}/series"), + &serde_json::json!({ "seriesIds": reversed }), + &token, + ); + let (status, _): (StatusCode, Option) = make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::NO_CONTENT); + + let req = get_request_with_auth(&format!("/api/v1/collections/{coll_id}/series"), &token); + let (_s, members): (StatusCode, Option>) = + make_json_request(app.clone(), req).await; + let members = members.unwrap(); + assert_eq!(members[0].id, series[2].id); + assert_eq!(members[2].id, series[0].id); + + // Remove the middle series. + let req = delete_request_with_auth( + &format!("/api/v1/collections/{coll_id}/series/{}", series[1].id), + &token, + ); + let (status, _): (StatusCode, Option) = make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::NO_CONTENT); + + // series/{id}/collections reverse lookup includes this collection. + let req = get_request_with_auth( + &format!("/api/v1/series/{}/collections", series[0].id), + &token, + ); + let (status, containers): (StatusCode, Option) = + make_json_request(app, req).await; + assert_eq!(status, StatusCode::OK); + let containers = containers.unwrap(); + assert_eq!(containers.total, 1); + assert_eq!(containers.items[0].id, coll_id); +} + +#[tokio::test] +async fn test_update_and_not_found() { + let (db, _t) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "admin", true).await; + let app = create_test_router(state).await; + + let req = post_json_request_with_auth( + "/api/v1/collections", + &serde_json::json!({ "name": "Old" }), + &token, + ); + let (_s, coll): (StatusCode, Option) = make_json_request(app.clone(), req).await; + let coll_id = coll.unwrap().id; + + let req = patch_json_request_with_auth( + &format!("/api/v1/collections/{coll_id}"), + &serde_json::json!({ "name": "New", "ordered": true }), + &token, + ); + let (status, updated): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::OK); + let updated = updated.unwrap(); + assert_eq!(updated.name, "New"); + assert!(updated.ordered); + + // Unknown collection -> 404. + let req = get_request_with_auth( + &format!("/api/v1/collections/{}", uuid::Uuid::new_v4()), + &token, + ); + let (status, _): (StatusCode, Option) = make_json_request(app, req).await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_add_nonexistent_series_returns_404() { + let (db, _t) = setup_test_db().await; + let _series = make_series(&db, "Solo").await; + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "admin", true).await; + let app = create_test_router(state).await; + + let req = post_json_request_with_auth( + "/api/v1/collections", + &serde_json::json!({ "name": "Coll" }), + &token, + ); + let (_s, coll): (StatusCode, Option) = make_json_request(app.clone(), req).await; + let coll_id = coll.unwrap().id; + + let req = post_json_request_with_auth( + &format!("/api/v1/collections/{coll_id}/series"), + &serde_json::json!({ "seriesIds": [uuid::Uuid::new_v4()] }), + &token, + ); + let (status, _): (StatusCode, Option) = make_json_request(app, req).await; + assert_eq!(status, StatusCode::NOT_FOUND); +} diff --git a/tests/api/mod.rs b/tests/api/mod.rs index 0662e46e..f9db6ba1 100644 --- a/tests/api/mod.rs +++ b/tests/api/mod.rs @@ -12,6 +12,7 @@ mod auth; mod books; mod bulk_metadata; mod bulk_operations; +mod collections; mod covers; mod current_user; mod duplicates; diff --git a/web/openapi.json b/web/openapi.json index d14beb17..eb89549a 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -5510,6 +5510,461 @@ ] } }, + "/api/v1/collections": { + "get": { + "tags": [ + "Collections" + ], + "summary": "List all collections.", + "operationId": "list_collections", + "responses": { + "200": { + "description": "Collections", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionListResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "post": { + "tags": [ + "Collections" + ], + "summary": "Create a collection.", + "operationId": "create_collection", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCollectionRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionDto" + } + } + } + }, + "400": { + "description": "Invalid name" + }, + "403": { + "description": "Forbidden" + }, + "409": { + "description": "A collection with that name already exists" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/collections/{collection_id}": { + "get": { + "tags": [ + "Collections" + ], + "summary": "Get a collection.", + "operationId": "get_collection", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Collection", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionDto" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "delete": { + "tags": [ + "Collections" + ], + "summary": "Delete a collection.", + "operationId": "delete_collection", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Deleted" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "patch": { + "tags": [ + "Collections" + ], + "summary": "Update a collection (rename / toggle ordered).", + "operationId": "update_collection", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCollectionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionDto" + } + } + } + }, + "400": { + "description": "Invalid name" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "409": { + "description": "Name already in use" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/collections/{collection_id}/series": { + "get": { + "tags": [ + "Collections" + ], + "summary": "Get the series in a collection (visibility-filtered, in stored order).", + "operationId": "get_collection_series", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Member series", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeriesDto" + } + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "put": { + "tags": [ + "Collections" + ], + "summary": "Set the manual order of a collection's series.", + "operationId": "reorder_collection_series", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReorderCollectionSeriesRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Reordered" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "post": { + "tags": [ + "Collections" + ], + "summary": "Add one or more series to a collection.", + "operationId": "add_collection_series", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddSeriesToCollectionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Updated collection", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionDto" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Collection or series not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/collections/{collection_id}/series/{series_id}": { + "delete": { + "tags": [ + "Collections" + ], + "summary": "Remove a series from a collection.", + "operationId": "remove_collection_series", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "series_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Removed (or was not a member)" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/collections/{collection_id}/thumbnail": { + "get": { + "tags": [ + "Collections" + ], + "summary": "Get a collection's thumbnail (the first visible member series' cover).", + "operationId": "get_collection_thumbnail", + "parameters": [ + { + "name": "collection_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "307": { + "description": "Redirect to the first member series thumbnail" + }, + "404": { + "description": "No visible member series" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, "/api/v1/duplicates": { "get": { "tags": [ @@ -11423,6 +11878,49 @@ ] } }, + "/api/v1/series/{series_id}/collections": { + "get": { + "tags": [ + "Collections" + ], + "summary": "List the collections that contain a given series.", + "operationId": "get_series_collections", + "parameters": [ + { + "name": "series_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Collections containing the series", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CollectionListResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, "/api/v1/series/{series_id}/cover": { "post": { "tags": [ @@ -19681,6 +20179,25 @@ } } }, + "AddSeriesToCollectionRequest": { + "type": "object", + "description": "Request to add one or more series to a collection.", + "required": [ + "seriesIds" + ], + "properties": { + "seriesIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "example": [ + "550e8400-e29b-41d4-a716-446655440001" + ] + } + } + }, "AddWantToReadRequest": { "type": "object", "description": "Request to add an entry to the queue. Exactly one of `series_id` / `book_id`\nmust be provided.", @@ -24016,6 +24533,70 @@ } } }, + "CollectionDto": { + "type": "object", + "description": "A collection of series.", + "required": [ + "id", + "name", + "ordered", + "seriesCount", + "createdAt", + "updatedAt" + ], + "properties": { + "createdAt": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "name": { + "type": "string", + "example": "Batman" + }, + "ordered": { + "type": "boolean", + "description": "When true, members are kept in manual order; otherwise sorted by title.", + "example": false + }, + "seriesCount": { + "type": "integer", + "format": "int64", + "description": "Number of member series visible to the requesting user.", + "example": 12, + "minimum": 0 + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "CollectionListResponse": { + "type": "object", + "description": "List of collections.", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CollectionDto" + } + }, + "total": { + "type": "integer", + "example": 3, + "minimum": 0 + } + } + }, "ConfigFieldDto": { "type": "object", "description": "Configuration field definition for documenting plugin config options", @@ -24294,6 +24875,24 @@ } } }, + "CreateCollectionRequest": { + "type": "object", + "description": "Request to create a collection.", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "example": "Batman" + }, + "ordered": { + "type": "boolean", + "description": "Defaults to `false` (members sorted by title).", + "example": false + } + } + }, "CreateExternalLinkRequest": { "type": "object", "description": "Request to create or update an external link for a series", @@ -36527,6 +37126,26 @@ } } }, + "ReorderCollectionSeriesRequest": { + "type": "object", + "description": "Request to set the manual order of a collection's series. IDs not currently\nmembers are ignored; omitted members keep their existing position.", + "required": [ + "seriesIds" + ], + "properties": { + "seriesIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "example": [ + "550e8400-e29b-41d4-a716-446655440002", + "550e8400-e29b-41d4-a716-446655440001" + ] + } + } + }, "ReplaceBookMetadataRequest": { "type": "object", "description": "PUT request for full replacement of book metadata\n\nAll metadata fields will be replaced with the values in this request.\nOmitting a field (or setting it to null) will clear that field.", @@ -41711,6 +42330,24 @@ } } }, + "UpdateCollectionRequest": { + "type": "object", + "description": "Request to update a collection. Absent fields are left unchanged.", + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "ordered": { + "type": [ + "boolean", + "null" + ] + } + } + }, "UpdateFilterPresetRequest": { "type": "object", "description": "Request body for updating an existing filter preset.\n\nTreated as a full replacement of the mutable fields. `scope` and `target`\nare immutable since the condition is validated against them on create.", diff --git a/web/src/types/api.generated.ts b/web/src/types/api.generated.ts index c939bbf0..d056e026 100644 --- a/web/src/types/api.generated.ts +++ b/web/src/types/api.generated.ts @@ -1796,6 +1796,96 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/collections": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List all collections. */ + get: operations["list_collections"]; + put?: never; + /** Create a collection. */ + post: operations["create_collection"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/collections/{collection_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a collection. */ + get: operations["get_collection"]; + put?: never; + post?: never; + /** Delete a collection. */ + delete: operations["delete_collection"]; + options?: never; + head?: never; + /** Update a collection (rename / toggle ordered). */ + patch: operations["update_collection"]; + trace?: never; + }; + "/api/v1/collections/{collection_id}/series": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get the series in a collection (visibility-filtered, in stored order). */ + get: operations["get_collection_series"]; + /** Set the manual order of a collection's series. */ + put: operations["reorder_collection_series"]; + /** Add one or more series to a collection. */ + post: operations["add_collection_series"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/collections/{collection_id}/series/{series_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Remove a series from a collection. */ + delete: operations["remove_collection_series"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/collections/{collection_id}/thumbnail": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a collection's thumbnail (the first visible member series' cover). */ + get: operations["get_collection_thumbnail"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/duplicates": { parameters: { query?: never; @@ -3951,6 +4041,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/series/{series_id}/collections": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List the collections that contain a given series. */ + get: operations["get_series_collections"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/series/{series_id}/cover": { parameters: { query?: never; @@ -7183,6 +7290,15 @@ export interface components { */ name: string; }; + /** @description Request to add one or more series to a collection. */ + AddSeriesToCollectionRequest: { + /** + * @example [ + * "550e8400-e29b-41d4-a716-446655440001" + * ] + */ + seriesIds: string[]; + }; /** * @description Request to add an entry to the queue. Exactly one of `series_id` / `book_id` * must be provided. @@ -9703,6 +9819,37 @@ export interface components { */ thumbnailsDeleted: number; }; + /** @description A collection of series. */ + CollectionDto: { + /** Format: date-time */ + createdAt: string; + /** + * Format: uuid + * @example 550e8400-e29b-41d4-a716-446655440000 + */ + id: string; + /** @example Batman */ + name: string; + /** + * @description When true, members are kept in manual order; otherwise sorted by title. + * @example false + */ + ordered: boolean; + /** + * Format: int64 + * @description Number of member series visible to the requesting user. + * @example 12 + */ + seriesCount: number; + /** Format: date-time */ + updatedAt: string; + }; + /** @description List of collections. */ + CollectionListResponse: { + items: components["schemas"]["CollectionDto"][]; + /** @example 3 */ + total: number; + }; /** @description Configuration field definition for documenting plugin config options */ ConfigFieldDto: { /** @description Default value if not provided */ @@ -9844,6 +9991,16 @@ export interface components { */ url: string; }; + /** @description Request to create a collection. */ + CreateCollectionRequest: { + /** @example Batman */ + name: string; + /** + * @description Defaults to `false` (members sorted by title). + * @example false + */ + ordered?: boolean; + }; /** @description Request to create or update an external link for a series */ CreateExternalLinkRequest: { /** @@ -16384,6 +16541,19 @@ export interface components { */ start: number; }; + /** + * @description Request to set the manual order of a collection's series. IDs not currently + * members are ignored; omitted members keep their existing position. + */ + ReorderCollectionSeriesRequest: { + /** + * @example [ + * "550e8400-e29b-41d4-a716-446655440002", + * "550e8400-e29b-41d4-a716-446655440001" + * ] + */ + seriesIds: string[]; + }; /** * @description PUT request for full replacement of book metadata * @@ -19220,6 +19390,11 @@ export interface components { /** @description Whether to lock year */ yearLock?: boolean | null; }; + /** @description Request to update a collection. Absent fields are left unchanged. */ + UpdateCollectionRequest: { + name?: string | null; + ordered?: boolean | null; + }; /** * @description Request body for updating an existing filter preset. * @@ -24297,6 +24472,378 @@ export interface operations { }; }; }; + list_collections: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Collections */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CollectionListResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + create_collection: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateCollectionRequest"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CollectionDto"]; + }; + }; + /** @description Invalid name */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description A collection with that name already exists */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + get_collection: { + parameters: { + query?: never; + header?: never; + path: { + collection_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Collection */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CollectionDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete_collection: { + parameters: { + query?: never; + header?: never; + path: { + collection_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deleted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + update_collection: { + parameters: { + query?: never; + header?: never; + path: { + collection_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateCollectionRequest"]; + }; + }; + responses: { + /** @description Updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CollectionDto"]; + }; + }; + /** @description Invalid name */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Name already in use */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + get_collection_series: { + parameters: { + query?: never; + header?: never; + path: { + collection_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Member series */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SeriesDto"][]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + reorder_collection_series: { + parameters: { + query?: never; + header?: never; + path: { + collection_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ReorderCollectionSeriesRequest"]; + }; + }; + responses: { + /** @description Reordered */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + add_collection_series: { + parameters: { + query?: never; + header?: never; + path: { + collection_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AddSeriesToCollectionRequest"]; + }; + }; + responses: { + /** @description Updated collection */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CollectionDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Collection or series not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + remove_collection_series: { + parameters: { + query?: never; + header?: never; + path: { + collection_id: string; + series_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Removed (or was not a member) */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + get_collection_thumbnail: { + parameters: { + query?: never; + header?: never; + path: { + collection_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Redirect to the first member series thumbnail */ + 307: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description No visible member series */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; list_duplicates: { parameters: { query?: never; @@ -28578,6 +29125,35 @@ export interface operations { }; }; }; + get_series_collections: { + parameters: { + query?: never; + header?: never; + path: { + series_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Collections containing the series */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CollectionListResponse"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; upload_series_cover: { parameters: { query?: never; From b0a4ca380227fb044ac99fc90d7db2120d29201b Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Mon, 15 Jun 2026 17:28:55 -0700 Subject: [PATCH 06/13] feat(web): add collections pages and management Add the frontend for shared collections of series: - A collections list page with cover thumbnails and a gated "New collection" modal - A collection detail page showing member series, with per-member removal and up/down reordering for ordered collections, plus gated edit and delete - An "Add to collection" menu on the series page that toggles membership across collections and can create new ones inline - A sidebar link and routes, the API client, and query hooks Mirror the new collection and read-list permissions in the frontend permission map so create/modify/delete controls only render for users who can manage collections. Covered by a component test. --- web/src/App.tsx | 24 ++ web/src/api/collections.ts | 71 ++++++ .../collections/AddToCollectionButton.tsx | 94 +++++++ .../collections/CollectionFormModal.test.tsx | 47 ++++ .../collections/CollectionFormModal.tsx | 101 ++++++++ web/src/components/layout/Sidebar.tsx | 9 + web/src/hooks/useCollections.ts | 163 ++++++++++++ web/src/pages/CollectionDetail.tsx | 235 ++++++++++++++++++ web/src/pages/Collections.tsx | 116 +++++++++ web/src/pages/SeriesDetail.tsx | 5 + web/src/types/permissions.ts | 48 ++++ 11 files changed, 913 insertions(+) create mode 100644 web/src/api/collections.ts create mode 100644 web/src/components/collections/AddToCollectionButton.tsx create mode 100644 web/src/components/collections/CollectionFormModal.test.tsx create mode 100644 web/src/components/collections/CollectionFormModal.tsx create mode 100644 web/src/hooks/useCollections.ts create mode 100644 web/src/pages/CollectionDetail.tsx create mode 100644 web/src/pages/Collections.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index cd62abc5..740b4283 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -14,6 +14,8 @@ import { setupApi } from "@/api/setup"; import { AppLayout } from "@/components/layout/AppLayout"; import { useEntityEvents } from "@/hooks/useEntityEvents"; import { BookDetail } from "@/pages/BookDetail"; +import { CollectionDetail } from "@/pages/CollectionDetail"; +import { Collections } from "@/pages/Collections"; import { Home } from "@/pages/Home"; import { LibraryPage } from "@/pages/Library"; import { LibraryJobsPage } from "@/pages/LibraryJobs"; @@ -214,6 +216,28 @@ function App() { } /> + + + + + + } + /> + + + + + + + } + /> + => { + const response = await api.get("/collections"); + return response.data.items; + }, + + get: async (id: string): Promise => { + const response = await api.get(`/collections/${id}`); + return response.data; + }, + + /** Member series, in stored order, filtered by the user's visibility. */ + getSeries: async (id: string): Promise => { + const response = await api.get(`/collections/${id}/series`); + return response.data; + }, + + create: async (body: CreateCollectionRequest): Promise => { + const response = await api.post("/collections", body); + return response.data; + }, + + update: async ( + id: string, + body: UpdateCollectionRequest, + ): Promise => { + const response = await api.patch(`/collections/${id}`, body); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`/collections/${id}`); + }, + + addSeries: async (id: string, seriesIds: string[]): Promise => { + const response = await api.post(`/collections/${id}/series`, { + seriesIds, + }); + return response.data; + }, + + removeSeries: async (id: string, seriesId: string): Promise => { + await api.delete(`/collections/${id}/series/${seriesId}`); + }, + + /** Set the full manual order of a collection's series. */ + reorder: async (id: string, seriesIds: string[]): Promise => { + await api.put(`/collections/${id}/series`, { seriesIds }); + }, + + /** Collections that contain a given series. */ + forSeries: async (seriesId: string): Promise => { + const response = await api.get( + `/series/${seriesId}/collections`, + ); + return response.data.items; + }, +}; diff --git a/web/src/components/collections/AddToCollectionButton.tsx b/web/src/components/collections/AddToCollectionButton.tsx new file mode 100644 index 00000000..e9c334e2 --- /dev/null +++ b/web/src/components/collections/AddToCollectionButton.tsx @@ -0,0 +1,94 @@ +import { ActionIcon, Loader, Menu, Tooltip } from "@mantine/core"; +import { IconCheck, IconFolderPlus, IconPlus } from "@tabler/icons-react"; +import { useState } from "react"; +import { + useAddSeriesToCollection, + useCollections, + useCollectionsForSeries, + useRemoveSeriesFromCollections, +} from "@/hooks/useCollections"; +import { CollectionFormModal } from "./CollectionFormModal"; + +/** + * Toggle membership of a series across collections, and create new ones inline. + * Render only for users with `collections:write`. + */ +export function AddToCollectionButton({ seriesId }: { seriesId: string }) { + const { data: collections, isLoading } = useCollections(); + const { data: memberOf } = useCollectionsForSeries(seriesId); + const add = useAddSeriesToCollection(); + const remove = useRemoveSeriesFromCollections(); + const [createOpen, setCreateOpen] = useState(false); + + const memberIds = new Set((memberOf ?? []).map((c) => c.id)); + const busy = add.isPending || remove.isPending; + + const toggle = (collectionId: string) => { + if (memberIds.has(collectionId)) { + remove.mutate({ collectionId, seriesId }); + } else { + add.mutate({ collectionId, seriesIds: [seriesId] }); + } + }; + + return ( + <> + + + + + + + + + + Collections + {isLoading ? ( + + + + ) : !collections || collections.length === 0 ? ( + No collections yet + ) : ( + collections.map((c) => ( + + ) : ( + + ) + } + onClick={() => toggle(c.id)} + > + {c.name} + + )) + )} + + } + onClick={() => setCreateOpen(true)} + > + New collection… + + + + + setCreateOpen(false)} + onCreated={(c) => + add.mutate({ collectionId: c.id, seriesIds: [seriesId] }) + } + /> + + ); +} diff --git a/web/src/components/collections/CollectionFormModal.test.tsx b/web/src/components/collections/CollectionFormModal.test.tsx new file mode 100644 index 00000000..aa658b7b --- /dev/null +++ b/web/src/components/collections/CollectionFormModal.test.tsx @@ -0,0 +1,47 @@ +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { collectionsApi } from "@/api/collections"; +import { renderWithProviders, screen, waitFor } from "@/test/utils"; +import { CollectionFormModal } from "./CollectionFormModal"; + +vi.mock("@/api/collections", () => ({ + collectionsApi: { + create: vi.fn().mockResolvedValue({ + id: "c1", + name: "Batman", + ordered: true, + seriesCount: 0, + createdAt: "2026-06-15T00:00:00Z", + updatedAt: "2026-06-15T00:00:00Z", + }), + update: vi.fn(), + }, +})); + +describe("CollectionFormModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates a collection with the entered name and ordered flag", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + const onCreated = vi.fn(); + renderWithProviders( + , + ); + + await user.type(screen.getByPlaceholderText("e.g. Batman"), "Batman"); + await user.click(screen.getByRole("checkbox")); + await user.click(screen.getByRole("button", { name: /create/i })); + + await waitFor(() => + expect(collectionsApi.create).toHaveBeenCalledWith({ + name: "Batman", + ordered: true, + }), + ); + await waitFor(() => expect(onCreated).toHaveBeenCalled()); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/web/src/components/collections/CollectionFormModal.tsx b/web/src/components/collections/CollectionFormModal.tsx new file mode 100644 index 00000000..a187e4b1 --- /dev/null +++ b/web/src/components/collections/CollectionFormModal.tsx @@ -0,0 +1,101 @@ +import { + Button, + Checkbox, + Group, + Modal, + Stack, + TextInput, +} from "@mantine/core"; +import { useEffect, useState } from "react"; +import type { Collection } from "@/api/collections"; +import { + useCreateCollection, + useUpdateCollection, +} from "@/hooks/useCollections"; + +interface CollectionFormModalProps { + opened: boolean; + onClose: () => void; + /** When provided, the modal edits this collection instead of creating one. */ + collection?: Collection; + /** Called with the created collection (create mode only). */ + onCreated?: (collection: Collection) => void; +} + +export function CollectionFormModal({ + opened, + onClose, + collection, + onCreated, +}: CollectionFormModalProps) { + const isEdit = Boolean(collection); + const [name, setName] = useState(""); + const [ordered, setOrdered] = useState(false); + + // Seed fields when (re)opening. + useEffect(() => { + if (opened) { + setName(collection?.name ?? ""); + setOrdered(collection?.ordered ?? false); + } + }, [opened, collection]); + + const createMutation = useCreateCollection(); + const updateMutation = useUpdateCollection(collection?.id ?? ""); + const pending = createMutation.isPending || updateMutation.isPending; + + const submit = () => { + const trimmed = name.trim(); + if (!trimmed) return; + if (isEdit) { + updateMutation.mutate( + { name: trimmed, ordered }, + { onSuccess: () => onClose() }, + ); + } else { + createMutation.mutate( + { name: trimmed, ordered }, + { + onSuccess: (created) => { + onCreated?.(created); + onClose(); + }, + }, + ); + } + }; + + return ( + + + setName(e.currentTarget.value)} + data-autofocus + required + /> + setOrdered(e.currentTarget.checked)} + /> + + + + + + + ); +} diff --git a/web/src/components/layout/Sidebar.tsx b/web/src/components/layout/Sidebar.tsx index 9f82907c..75e95667 100644 --- a/web/src/components/layout/Sidebar.tsx +++ b/web/src/components/layout/Sidebar.tsx @@ -27,6 +27,7 @@ import { IconFileExport, IconFileTypePdf, IconHome, + IconLayoutGrid, IconLink, IconLogout, IconPhoto, @@ -405,6 +406,14 @@ export function Sidebar({ onNavigate }: SidebarProps = {}) { active={currentPath === "/want-to-read"} onClick={onNavigate} /> + } + active={currentPath.startsWith("/collections")} + onClick={onNavigate} + /> {hasRecommendationPlugin && ( ({ + queryKey: [COLLECTIONS_KEY], + queryFn: collectionsApi.list, + }); +} + +export function useCollection(id: string | undefined) { + return useQuery({ + queryKey: [COLLECTIONS_KEY, id], + queryFn: () => collectionsApi.get(id ?? ""), + enabled: Boolean(id), + }); +} + +export function useCollectionSeries(id: string | undefined) { + return useQuery({ + queryKey: [COLLECTIONS_KEY, id, "series"], + queryFn: () => collectionsApi.getSeries(id ?? ""), + enabled: Boolean(id), + }); +} + +export function useCollectionsForSeries(seriesId: string | undefined) { + return useQuery({ + queryKey: ["series", seriesId, "collections"], + queryFn: () => collectionsApi.forSeries(seriesId ?? ""), + enabled: Boolean(seriesId), + }); +} + +function notifyError(title: string) { + return (error: Error) => + notifications.show({ + title, + message: error.message || "Unknown error", + color: "red", + }); +} + +export function useCreateCollection() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body: CreateCollectionRequest) => collectionsApi.create(body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [COLLECTIONS_KEY] }); + }, + onError: notifyError("Failed to create collection"), + }); +} + +export function useUpdateCollection(id: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (body: UpdateCollectionRequest) => + collectionsApi.update(id, body), + onSuccess: (data) => { + queryClient.setQueryData([COLLECTIONS_KEY, id], data); + queryClient.invalidateQueries({ queryKey: [COLLECTIONS_KEY] }); + }, + onError: notifyError("Failed to update collection"), + }); +} + +export function useDeleteCollection() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => collectionsApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [COLLECTIONS_KEY] }); + }, + onError: notifyError("Failed to delete collection"), + }); +} + +export function useAddSeriesToCollection() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + collectionId, + seriesIds, + }: { + collectionId: string; + seriesIds: string[]; + }) => collectionsApi.addSeries(collectionId, seriesIds), + onSuccess: (_data, { collectionId }) => { + queryClient.invalidateQueries({ + queryKey: [COLLECTIONS_KEY, collectionId], + }); + queryClient.invalidateQueries({ queryKey: [COLLECTIONS_KEY] }); + queryClient.invalidateQueries({ queryKey: ["series"] }); + }, + onError: notifyError("Failed to add series to collection"), + }); +} + +export function useRemoveSeriesFromCollection(collectionId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (seriesId: string) => + collectionsApi.removeSeries(collectionId, seriesId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [COLLECTIONS_KEY, collectionId], + }); + queryClient.invalidateQueries({ queryKey: [COLLECTIONS_KEY] }); + }, + onError: notifyError("Failed to remove series from collection"), + }); +} + +/** + * Remove a series from a collection identified at call time (for menus that act + * across many collections, where the id isn't known when the hook is created). + */ +export function useRemoveSeriesFromCollections() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + collectionId, + seriesId, + }: { + collectionId: string; + seriesId: string; + }) => collectionsApi.removeSeries(collectionId, seriesId), + onSuccess: (_data, { collectionId, seriesId }) => { + queryClient.invalidateQueries({ + queryKey: [COLLECTIONS_KEY, collectionId], + }); + queryClient.invalidateQueries({ queryKey: [COLLECTIONS_KEY] }); + queryClient.invalidateQueries({ + queryKey: ["series", seriesId, "collections"], + }); + }, + onError: notifyError("Failed to remove series from collection"), + }); +} + +export function useReorderCollection(collectionId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (seriesIds: string[]) => + collectionsApi.reorder(collectionId, seriesIds), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [COLLECTIONS_KEY, collectionId, "series"], + }); + }, + onError: notifyError("Failed to reorder collection"), + }); +} diff --git a/web/src/pages/CollectionDetail.tsx b/web/src/pages/CollectionDetail.tsx new file mode 100644 index 00000000..2f8b2ce8 --- /dev/null +++ b/web/src/pages/CollectionDetail.tsx @@ -0,0 +1,235 @@ +import { + ActionIcon, + Badge, + Button, + Center, + Container, + Group, + Modal, + SimpleGrid, + Skeleton, + Stack, + Text, + Title, + Tooltip, +} from "@mantine/core"; +import { + IconChevronDown, + IconChevronUp, + IconEdit, + IconTrash, + IconX, +} from "@tabler/icons-react"; +import { useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { CollectionFormModal } from "@/components/collections/CollectionFormModal"; +import { MediaCard } from "@/components/library/MediaCard"; +import { + useCollection, + useCollectionSeries, + useDeleteCollection, + useRemoveSeriesFromCollection, + useReorderCollection, +} from "@/hooks/useCollections"; +import { usePermissions } from "@/hooks/usePermissions"; +import type { Series } from "@/types"; +import { PERMISSIONS } from "@/types/permissions"; + +export function CollectionDetail() { + const { collectionId } = useParams<{ collectionId: string }>(); + const navigate = useNavigate(); + const { hasPermission } = usePermissions(); + const canWrite = hasPermission(PERMISSIONS.COLLECTIONS_WRITE); + const canDelete = hasPermission(PERMISSIONS.COLLECTIONS_DELETE); + + const { data: collection, isLoading } = useCollection(collectionId); + const { data: series } = useCollectionSeries(collectionId); + + const removeMutation = useRemoveSeriesFromCollection(collectionId ?? ""); + const reorderMutation = useReorderCollection(collectionId ?? ""); + const deleteMutation = useDeleteCollection(); + + const [editOpen, setEditOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + + const members: Series[] = series ?? []; + const reorderable = canWrite && Boolean(collection?.ordered); + + const move = (index: number, delta: number) => { + const target = index + delta; + if (target < 0 || target >= members.length) return; + const ids = members.map((s) => s.id); + [ids[index], ids[target]] = [ids[target], ids[index]]; + reorderMutation.mutate(ids); + }; + + if (isLoading) { + return ( + + + + {Array.from({ length: 6 }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static skeletons + + ))} + + + ); + } + + if (!collection) { + return ( + +
+ Collection not found. +
+
+ ); + } + + return ( + + + + + {collection.name} + + {collection.seriesCount} series + {collection.ordered && ( + + Ordered + + )} + + {(canWrite || canDelete) && ( + + {canWrite && ( + + )} + {canDelete && ( + + )} + + )} + + + {members.length === 0 ? ( +
+ + This collection has no series yet. + {canWrite && ( + + Open a series and use "Add to collection". + + )} + +
+ ) : ( + + {members.map((s, index) => ( + + + {canWrite && ( + + {reorderable && ( + <> + + move(index, -1)} + aria-label="Move up" + > + + + + + move(index, 1)} + aria-label="Move down" + > + + + + + )} + + removeMutation.mutate(s.id)} + aria-label="Remove from collection" + > + + + + + )} + + ))} + + )} + + setEditOpen(false)} + collection={collection} + /> + + setDeleteOpen(false)} + title="Delete collection" + centered + > + + + Delete {collection.name}? The series themselves are + not affected. + + + + + + + +
+ ); +} diff --git a/web/src/pages/Collections.tsx b/web/src/pages/Collections.tsx new file mode 100644 index 00000000..f88d3ac5 --- /dev/null +++ b/web/src/pages/Collections.tsx @@ -0,0 +1,116 @@ +import { + Box, + Button, + Card, + Center, + Container, + Group, + Image, + SimpleGrid, + Skeleton, + Stack, + Text, + Title, +} from "@mantine/core"; +import { IconLayoutGrid, IconPlus } from "@tabler/icons-react"; +import { useState } from "react"; +import { Link } from "react-router-dom"; +import type { Collection } from "@/api/collections"; +import { CollectionFormModal } from "@/components/collections/CollectionFormModal"; +import { useCollections } from "@/hooks/useCollections"; +import { usePermissions } from "@/hooks/usePermissions"; +import { PERMISSIONS } from "@/types/permissions"; + +const NO_COVER = + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='300'%3E%3Crect fill='%23ddd' width='200' height='300'/%3E%3Ctext fill='%23999' font-family='sans-serif' font-size='14' x='50%25' y='50%25' text-anchor='middle' dy='.3em'%3ENo Cover%3C/text%3E%3C/svg%3E"; + +function CollectionCard({ collection }: { collection: Collection }) { + return ( + + + {collection.name} + + + + {collection.name} + + + {collection.seriesCount} series + + + + ); +} + +export function Collections() { + const { data: collections, isLoading } = useCollections(); + const { hasPermission } = usePermissions(); + const canWrite = hasPermission(PERMISSIONS.COLLECTIONS_WRITE); + const [createOpen, setCreateOpen] = useState(false); + + return ( + + + + + Collections + + {canWrite && ( + + )} + + + {isLoading ? ( + + {Array.from({ length: 6 }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static skeletons + + ))} + + ) : !collections || collections.length === 0 ? ( +
+ + + No collections yet. + {canWrite && ( + + Create one, then add series to it from a series page. + + )} + +
+ ) : ( + + {collections.map((collection) => ( + + ))} + + )} + + setCreateOpen(false)} + /> +
+ ); +} diff --git a/web/src/pages/SeriesDetail.tsx b/web/src/pages/SeriesDetail.tsx index 11817a31..32c3bc84 100644 --- a/web/src/pages/SeriesDetail.tsx +++ b/web/src/pages/SeriesDetail.tsx @@ -49,6 +49,7 @@ import { seriesMetadataApi } from "@/api/seriesMetadata"; import { settingsApi } from "@/api/settings"; import { sharingTagsApi } from "@/api/sharingTags"; import { AuthorsList } from "@/components/book/AuthorsList"; +import { AddToCollectionButton } from "@/components/collections/AddToCollectionButton"; import { ExternalIdEditModal, MetadataLabel } from "@/components/common"; import { BulkSelectionToolbar } from "@/components/library/BulkSelectionToolbar"; import { MetadataApplyFlow } from "@/components/metadata"; @@ -105,6 +106,7 @@ export function SeriesDetail() { const queryClient = useQueryClient(); const { isAdmin, hasPermission } = usePermissions(); const canEditSeries = hasPermission(PERMISSIONS.SERIES_WRITE); + const canManageCollections = hasPermission(PERMISSIONS.COLLECTIONS_WRITE); const [summaryOpened, { toggle: toggleSummary }] = useDisclosure(false); const [editModalOpened, { open: openEditModal, close: closeEditModal }] = useDisclosure(false); @@ -641,6 +643,9 @@ export function SeriesDetail() { id={series.id} wantToRead={series.wantToRead} /> + {canManageCollections && ( + + )} diff --git a/web/src/types/permissions.ts b/web/src/types/permissions.ts index 5fcc3438..33e688e2 100644 --- a/web/src/types/permissions.ts +++ b/web/src/types/permissions.ts @@ -18,6 +18,16 @@ export const PERMISSIONS = { BOOKS_WRITE: "books-write", BOOKS_DELETE: "books-delete", + // Collections (shared groupings of series) + COLLECTIONS_READ: "collections-read", + COLLECTIONS_WRITE: "collections-write", + COLLECTIONS_DELETE: "collections-delete", + + // Read lists (shared groupings of books) + READLISTS_READ: "readlists-read", + READLISTS_WRITE: "readlists-write", + READLISTS_DELETE: "readlists-delete", + // Pages (image serving) PAGES_READ: "pages-read", @@ -89,6 +99,16 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [ label: "Read Pages", description: "View book pages/images", }, + { + value: PERMISSIONS.COLLECTIONS_READ, + label: "Read Collections", + description: "Browse collections", + }, + { + value: PERMISSIONS.READLISTS_READ, + label: "Read Read Lists", + description: "Browse read lists", + }, ], }, { @@ -141,6 +161,26 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [ label: "Delete Books", description: "Delete books", }, + { + value: PERMISSIONS.COLLECTIONS_WRITE, + label: "Write Collections", + description: "Create and modify collections", + }, + { + value: PERMISSIONS.COLLECTIONS_DELETE, + label: "Delete Collections", + description: "Delete collections", + }, + { + value: PERMISSIONS.READLISTS_WRITE, + label: "Write Read Lists", + description: "Create and modify read lists", + }, + { + value: PERMISSIONS.READLISTS_DELETE, + label: "Delete Read Lists", + description: "Delete read lists", + }, ], }, { @@ -230,6 +270,8 @@ export const ROLE_PERMISSIONS: Record = { PERMISSIONS.PAGES_READ, PERMISSIONS.PROGRESS_READ, PERMISSIONS.PROGRESS_WRITE, + PERMISSIONS.COLLECTIONS_READ, + PERMISSIONS.READLISTS_READ, PERMISSIONS.API_KEYS_READ, PERMISSIONS.API_KEYS_WRITE, PERMISSIONS.API_KEYS_DELETE, @@ -243,6 +285,8 @@ export const ROLE_PERMISSIONS: Record = { PERMISSIONS.PAGES_READ, PERMISSIONS.PROGRESS_READ, PERMISSIONS.PROGRESS_WRITE, + PERMISSIONS.COLLECTIONS_READ, + PERMISSIONS.READLISTS_READ, PERMISSIONS.API_KEYS_READ, PERMISSIONS.API_KEYS_WRITE, PERMISSIONS.API_KEYS_DELETE, @@ -253,6 +297,10 @@ export const ROLE_PERMISSIONS: Record = { PERMISSIONS.SERIES_DELETE, PERMISSIONS.BOOKS_WRITE, PERMISSIONS.BOOKS_DELETE, + PERMISSIONS.COLLECTIONS_WRITE, + PERMISSIONS.COLLECTIONS_DELETE, + PERMISSIONS.READLISTS_WRITE, + PERMISSIONS.READLISTS_DELETE, PERMISSIONS.TASKS_READ, PERMISSIONS.TASKS_WRITE, ], From 21a2cbbfdfc286df5b1363d302de869200580df6 Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Mon, 15 Jun 2026 17:44:31 -0700 Subject: [PATCH 07/13] feat(api): add read list endpoints Expose shared, ordered read lists of books over /api/v1/readlists: - List, create (with an optional summary), get, rename / edit summary / toggle ordering, and delete read lists - Manage members: add one or many books, remove, and set manual reading order - Browse members as full book DTOs, filtered by the requesting user's sharing-tag visibility, with the visible count on each read list - A thumbnail endpoint that redirects to the first visible member's cover, and a reverse lookup of the read lists containing a book Reads are available to every role; create/modify require read-list write and delete requires read-list delete. The update request distinguishes an omitted summary from an explicit null (which clears it). Register the paths and schemas in the OpenAPI doc and regenerate the committed spec and types. Covered by integration tests including the permission matrix. --- crates/codex-api/src/docs.rs | 21 + crates/codex-api/src/routes/v1/dto/mod.rs | 2 + .../codex-api/src/routes/v1/dto/readlist.rs | 115 +++ .../codex-api/src/routes/v1/handlers/mod.rs | 2 + .../src/routes/v1/handlers/readlists.rs | 460 ++++++++++++ crates/codex-api/src/routes/v1/routes/mod.rs | 2 + .../src/routes/v1/routes/readlists.rs | 53 ++ docs/api/openapi.json | 657 ++++++++++++++++++ tests/api/mod.rs | 1 + tests/api/readlists.rs | 283 ++++++++ web/openapi.json | 657 ++++++++++++++++++ web/src/types/api.generated.ts | 582 ++++++++++++++++ 12 files changed, 2835 insertions(+) create mode 100644 crates/codex-api/src/routes/v1/dto/readlist.rs create mode 100644 crates/codex-api/src/routes/v1/handlers/readlists.rs create mode 100644 crates/codex-api/src/routes/v1/routes/readlists.rs create mode 100644 tests/api/readlists.rs diff --git a/crates/codex-api/src/docs.rs b/crates/codex-api/src/docs.rs index 41546be4..ed9214cd 100644 --- a/crates/codex-api/src/docs.rs +++ b/crates/codex-api/src/docs.rs @@ -375,6 +375,19 @@ The following paths are exempt from rate limiting: v1::handlers::get_collection_thumbnail, v1::handlers::get_series_collections, + // Read lists endpoints + v1::handlers::list_readlists, + v1::handlers::create_readlist, + v1::handlers::get_readlist, + v1::handlers::update_readlist, + v1::handlers::delete_readlist, + v1::handlers::get_readlist_books, + v1::handlers::add_readlist_books, + v1::handlers::remove_readlist_book, + v1::handlers::reorder_readlist_books, + v1::handlers::get_readlist_thumbnail, + v1::handlers::get_book_readlists, + // Bulk operations endpoints v1::handlers::bulk_mark_books_as_read, v1::handlers::bulk_mark_books_as_unread, @@ -950,6 +963,14 @@ The following paths are exempt from rate limiting: v1::dto::AddSeriesToCollectionRequest, v1::dto::ReorderCollectionSeriesRequest, + // Read lists DTOs + v1::dto::ReadListDto, + v1::dto::ReadListListResponse, + v1::dto::CreateReadListRequest, + v1::dto::UpdateReadListRequest, + v1::dto::AddBooksToReadListRequest, + v1::dto::ReorderReadListBooksRequest, + // Bulk operations DTOs v1::dto::BulkBooksRequest, v1::dto::BulkAnalyzeBooksRequest, diff --git a/crates/codex-api/src/routes/v1/dto/mod.rs b/crates/codex-api/src/routes/v1/dto/mod.rs index bc029ead..3e477c44 100644 --- a/crates/codex-api/src/routes/v1/dto/mod.rs +++ b/crates/codex-api/src/routes/v1/dto/mod.rs @@ -25,6 +25,7 @@ pub mod pdf_cache; pub mod plugin_storage; pub mod plugins; pub mod read_progress; +pub mod readlist; pub mod recommendations; pub mod release; pub mod scan; @@ -64,6 +65,7 @@ pub use pdf_cache::*; pub use plugin_storage::*; pub use plugins::*; pub use read_progress::*; +pub use readlist::*; #[allow(unused_imports)] pub use recommendations::*; #[allow(unused_imports)] diff --git a/crates/codex-api/src/routes/v1/dto/readlist.rs b/crates/codex-api/src/routes/v1/dto/readlist.rs new file mode 100644 index 00000000..f667dbe4 --- /dev/null +++ b/crates/codex-api/src/routes/v1/dto/readlist.rs @@ -0,0 +1,115 @@ +//! DTOs for read lists (shared, ordered groupings of books across series). + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Deserializer, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +/// Deserialize a nullable field into a "double option" so the handler can tell +/// "field absent" (`None` → leave unchanged) from "field present and null" +/// (`Some(None)` → clear). Without this, serde collapses an explicit `null` +/// into the outer `None`. +fn double_option<'de, T, D>(deserializer: D) -> Result>, D::Error> +where + T: Deserialize<'de>, + D: Deserializer<'de>, +{ + Deserialize::deserialize(deserializer).map(Some) +} + +/// A read list. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ReadListDto { + #[schema(example = "550e8400-e29b-41d4-a716-446655440000")] + pub id: Uuid, + #[schema(example = "Civil War")] + pub name: String, + /// Optional description (Komga read lists carry a summary). + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + /// When true, members are kept in manual reading order; otherwise sorted by + /// release date. + #[schema(example = true)] + pub ordered: bool, + /// Number of member books visible to the requesting user. + #[schema(example = 24)] + pub book_count: u64, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl ReadListDto { + pub fn from_model(model: codex_db::entities::read_lists::Model, book_count: u64) -> Self { + Self { + id: model.id, + name: model.name, + summary: model.summary, + ordered: model.ordered, + book_count, + created_at: model.created_at, + updated_at: model.updated_at, + } + } +} + +/// List of read lists. +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ReadListListResponse { + pub items: Vec, + #[schema(example = 3)] + pub total: usize, +} + +/// Request to create a read list. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateReadListRequest { + #[schema(example = "Civil War")] + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summary: Option, + /// Defaults to `true` (manual reading order). + #[serde(default = "default_true")] + #[schema(example = true)] + pub ordered: bool, +} + +fn default_true() -> bool { + true +} + +/// Request to update a read list. Absent fields are left unchanged. To clear the +/// summary, send `summary: null` explicitly. +#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateReadListRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// `Some(Some(text))` sets it, `Some(None)` clears it, absent leaves it. + #[serde( + default, + deserialize_with = "double_option", + skip_serializing_if = "Option::is_none" + )] + pub summary: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ordered: Option, +} + +/// Request to add one or more books to a read list. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AddBooksToReadListRequest { + #[schema(example = json!(["550e8400-e29b-41d4-a716-446655440001"]))] + pub book_ids: Vec, +} + +/// Request to set the manual order of a read list's books. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ReorderReadListBooksRequest { + #[schema(example = json!(["550e8400-e29b-41d4-a716-446655440002", "550e8400-e29b-41d4-a716-446655440001"]))] + pub book_ids: Vec, +} diff --git a/crates/codex-api/src/routes/v1/handlers/mod.rs b/crates/codex-api/src/routes/v1/handlers/mod.rs index 30740ba2..7934482e 100644 --- a/crates/codex-api/src/routes/v1/handlers/mod.rs +++ b/crates/codex-api/src/routes/v1/handlers/mod.rs @@ -64,6 +64,7 @@ pub mod plugin_actions; pub mod plugin_storage; pub mod plugins; pub mod read_progress; +pub mod readlists; pub mod recommendations; pub mod releases; pub mod scan; @@ -93,6 +94,7 @@ pub use libraries::*; pub use metrics::*; pub use pages::*; pub use read_progress::*; +pub use readlists::*; pub use scan::*; pub use series::*; pub use users::*; diff --git a/crates/codex-api/src/routes/v1/handlers/readlists.rs b/crates/codex-api/src/routes/v1/handlers/readlists.rs new file mode 100644 index 00000000..f1a95aa3 --- /dev/null +++ b/crates/codex-api/src/routes/v1/handlers/readlists.rs @@ -0,0 +1,460 @@ +//! Handlers for read lists (shared, ordered groupings of books across series). +//! +//! Reads require `ReadListsRead` (granted to every role); create/modify require +//! `ReadListsWrite`; delete requires `ReadListsDelete` (write/delete are in the +//! Maintainer bundle). Member lists and counts are filtered through the +//! requesting user's sharing-tag visibility. + +use super::super::dto::{ + AddBooksToReadListRequest, BookDto, CreateReadListRequest, ReadListDto, ReadListListResponse, + ReorderReadListBooksRequest, UpdateReadListRequest, +}; +use crate::require_permission; +use crate::{ + error::ApiError, + extractors::{AuthContext, AuthState, ContentFilter, FlexibleAuthContext}, + permissions::Permission, +}; +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, + response::Redirect, +}; +use codex_db::repositories::{BookRepository, ReadListRepository, visibility::SeriesVisibility}; +use std::sync::Arc; +use utoipa::OpenApi; +use uuid::Uuid; + +#[derive(OpenApi)] +#[openapi( + paths( + list_readlists, + create_readlist, + get_readlist, + update_readlist, + delete_readlist, + get_readlist_books, + add_readlist_books, + remove_readlist_book, + reorder_readlist_books, + get_readlist_thumbnail, + get_book_readlists, + ), + components(schemas( + ReadListDto, + ReadListListResponse, + CreateReadListRequest, + UpdateReadListRequest, + AddBooksToReadListRequest, + ReorderReadListBooksRequest, + )), + tags( + (name = "Read Lists", description = "Shared, ordered groupings of books across series") + ) +)] +#[allow(dead_code)] // OpenAPI documentation struct - referenced by utoipa derive macros +pub struct ReadListsApi; + +fn internal(context: &str) -> impl Fn(E) -> ApiError + '_ { + move |e| ApiError::Internal(format!("{context}: {e}")) +} + +async fn readlist_dto( + db: &sea_orm::DatabaseConnection, + model: codex_db::entities::read_lists::Model, + vis: Option<&SeriesVisibility>, +) -> Result { + let count = ReadListRepository::count_books(db, model.id, vis) + .await + .map_err(internal("Failed to count read list books"))?; + Ok(ReadListDto::from_model(model, count)) +} + +async fn user_visibility( + state: &AuthState, + user_id: Uuid, +) -> Result, ApiError> { + let filter = ContentFilter::for_user(&state.db, user_id) + .await + .map_err(internal("Failed to load content filter"))?; + Ok(filter.to_visibility()) +} + +async fn ensure_readlist_exists(state: &AuthState, read_list_id: Uuid) -> Result<(), ApiError> { + if ReadListRepository::get_by_id(&state.db, read_list_id) + .await + .map_err(internal("Failed to fetch read list"))? + .is_none() + { + return Err(ApiError::NotFound("Read list not found".to_string())); + } + Ok(()) +} + +/// List all read lists. +#[utoipa::path( + get, + path = "/api/v1/readlists", + responses( + (status = 200, description = "Read lists", body = ReadListListResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Read Lists" +)] +pub async fn list_readlists( + State(state): State>, + auth: AuthContext, +) -> Result, ApiError> { + require_permission!(auth, Permission::ReadListsRead)?; + let vis = user_visibility(&state, auth.user_id).await?; + + let read_lists = ReadListRepository::list_all(&state.db) + .await + .map_err(internal("Failed to list read lists"))?; + + let mut items = Vec::with_capacity(read_lists.len()); + for model in read_lists { + items.push(readlist_dto(&state.db, model, vis.as_ref()).await?); + } + let total = items.len(); + Ok(Json(ReadListListResponse { items, total })) +} + +/// Create a read list. +#[utoipa::path( + post, + path = "/api/v1/readlists", + request_body = CreateReadListRequest, + responses( + (status = 201, description = "Created", body = ReadListDto), + (status = 400, description = "Invalid name"), + (status = 403, description = "Forbidden"), + (status = 409, description = "A read list with that name already exists"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Read Lists" +)] +pub async fn create_readlist( + State(state): State>, + auth: AuthContext, + Json(request): Json, +) -> Result<(StatusCode, Json), ApiError> { + require_permission!(auth, Permission::ReadListsWrite)?; + + let name = request.name.trim(); + if name.is_empty() { + return Err(ApiError::BadRequest( + "Read list name cannot be empty".to_string(), + )); + } + if ReadListRepository::get_by_name(&state.db, name) + .await + .map_err(internal("Failed to check read list name"))? + .is_some() + { + return Err(ApiError::Conflict(format!( + "A read list named '{name}' already exists" + ))); + } + + let model = + ReadListRepository::create(&state.db, name, request.summary.as_deref(), request.ordered) + .await + .map_err(internal("Failed to create read list"))?; + Ok((StatusCode::CREATED, Json(ReadListDto::from_model(model, 0)))) +} + +/// Get a read list. +#[utoipa::path( + get, + path = "/api/v1/readlists/{read_list_id}", + responses( + (status = 200, description = "Read list", body = ReadListDto), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Read Lists" +)] +pub async fn get_readlist( + State(state): State>, + auth: AuthContext, + Path(read_list_id): Path, +) -> Result, ApiError> { + require_permission!(auth, Permission::ReadListsRead)?; + let model = ReadListRepository::get_by_id(&state.db, read_list_id) + .await + .map_err(internal("Failed to fetch read list"))? + .ok_or_else(|| ApiError::NotFound("Read list not found".to_string()))?; + let vis = user_visibility(&state, auth.user_id).await?; + Ok(Json(readlist_dto(&state.db, model, vis.as_ref()).await?)) +} + +/// Update a read list (rename / edit summary / toggle ordered). +#[utoipa::path( + patch, + path = "/api/v1/readlists/{read_list_id}", + request_body = UpdateReadListRequest, + responses( + (status = 200, description = "Updated", body = ReadListDto), + (status = 400, description = "Invalid name"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + (status = 409, description = "Name already in use"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Read Lists" +)] +pub async fn update_readlist( + State(state): State>, + auth: AuthContext, + Path(read_list_id): Path, + Json(request): Json, +) -> Result, ApiError> { + require_permission!(auth, Permission::ReadListsWrite)?; + + if let Some(ref new_name) = request.name { + let trimmed = new_name.trim(); + if trimmed.is_empty() { + return Err(ApiError::BadRequest( + "Read list name cannot be empty".to_string(), + )); + } + if let Some(existing) = ReadListRepository::get_by_name(&state.db, trimmed) + .await + .map_err(internal("Failed to check read list name"))? + && existing.id != read_list_id + { + return Err(ApiError::Conflict(format!( + "A read list named '{trimmed}' already exists" + ))); + } + } + + let summary = request.summary.as_ref().map(|inner| inner.as_deref()); + let model = ReadListRepository::update( + &state.db, + read_list_id, + request.name.as_deref().map(str::trim), + summary, + request.ordered, + ) + .await + .map_err(internal("Failed to update read list"))? + .ok_or_else(|| ApiError::NotFound("Read list not found".to_string()))?; + + let vis = user_visibility(&state, auth.user_id).await?; + Ok(Json(readlist_dto(&state.db, model, vis.as_ref()).await?)) +} + +/// Delete a read list. +#[utoipa::path( + delete, + path = "/api/v1/readlists/{read_list_id}", + responses( + (status = 204, description = "Deleted"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Read Lists" +)] +pub async fn delete_readlist( + State(state): State>, + auth: AuthContext, + Path(read_list_id): Path, +) -> Result { + require_permission!(auth, Permission::ReadListsDelete)?; + let deleted = ReadListRepository::delete(&state.db, read_list_id) + .await + .map_err(internal("Failed to delete read list"))?; + if !deleted { + return Err(ApiError::NotFound("Read list not found".to_string())); + } + Ok(StatusCode::NO_CONTENT) +} + +/// Get the books in a read list (visibility-filtered, in stored order). +#[utoipa::path( + get, + path = "/api/v1/readlists/{read_list_id}/books", + responses( + (status = 200, description = "Member books", body = [BookDto]), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Read Lists" +)] +pub async fn get_readlist_books( + State(state): State>, + auth: AuthContext, + Path(read_list_id): Path, +) -> Result>, ApiError> { + require_permission!(auth, Permission::ReadListsRead)?; + ensure_readlist_exists(&state, read_list_id).await?; + + let vis = user_visibility(&state, auth.user_id).await?; + let members = ReadListRepository::get_books(&state.db, read_list_id, vis.as_ref()) + .await + .map_err(internal("Failed to fetch read list books"))?; + + let dtos = super::books::books_to_dtos(&state.db, auth.user_id, members).await?; + Ok(Json(dtos)) +} + +/// Add one or more books to a read list. +#[utoipa::path( + post, + path = "/api/v1/readlists/{read_list_id}/books", + request_body = AddBooksToReadListRequest, + responses( + (status = 200, description = "Updated read list", body = ReadListDto), + (status = 403, description = "Forbidden"), + (status = 404, description = "Read list or book not found"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Read Lists" +)] +pub async fn add_readlist_books( + State(state): State>, + auth: AuthContext, + Path(read_list_id): Path, + Json(request): Json, +) -> Result, ApiError> { + require_permission!(auth, Permission::ReadListsWrite)?; + let model = ReadListRepository::get_by_id(&state.db, read_list_id) + .await + .map_err(internal("Failed to fetch read list"))? + .ok_or_else(|| ApiError::NotFound("Read list not found".to_string()))?; + + for book_id in &request.book_ids { + if BookRepository::get_by_id(&state.db, *book_id) + .await + .map_err(internal("Failed to look up book"))? + .is_none() + { + return Err(ApiError::NotFound(format!("Book {book_id} not found"))); + } + ReadListRepository::add_book(&state.db, read_list_id, *book_id) + .await + .map_err(internal("Failed to add book to read list"))?; + } + + let vis = user_visibility(&state, auth.user_id).await?; + Ok(Json(readlist_dto(&state.db, model, vis.as_ref()).await?)) +} + +/// Remove a book from a read list. +#[utoipa::path( + delete, + path = "/api/v1/readlists/{read_list_id}/books/{book_id}", + responses( + (status = 204, description = "Removed (or was not a member)"), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Read Lists" +)] +pub async fn remove_readlist_book( + State(state): State>, + auth: AuthContext, + Path((read_list_id, book_id)): Path<(Uuid, Uuid)>, +) -> Result { + require_permission!(auth, Permission::ReadListsWrite)?; + ReadListRepository::remove_book(&state.db, read_list_id, book_id) + .await + .map_err(internal("Failed to remove book from read list"))?; + Ok(StatusCode::NO_CONTENT) +} + +/// Set the manual order of a read list's books. +#[utoipa::path( + put, + path = "/api/v1/readlists/{read_list_id}/books", + request_body = ReorderReadListBooksRequest, + responses( + (status = 204, description = "Reordered"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Read Lists" +)] +pub async fn reorder_readlist_books( + State(state): State>, + auth: AuthContext, + Path(read_list_id): Path, + Json(request): Json, +) -> Result { + require_permission!(auth, Permission::ReadListsWrite)?; + ensure_readlist_exists(&state, read_list_id).await?; + ReadListRepository::reorder(&state.db, read_list_id, &request.book_ids) + .await + .map_err(internal("Failed to reorder read list books"))?; + Ok(StatusCode::NO_CONTENT) +} + +/// Get a read list's thumbnail (the first visible member book's cover). +#[utoipa::path( + get, + path = "/api/v1/readlists/{read_list_id}/thumbnail", + responses( + (status = 307, description = "Redirect to the first member book thumbnail"), + (status = 404, description = "No visible member books"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Read Lists" +)] +pub async fn get_readlist_thumbnail( + State(state): State>, + FlexibleAuthContext(auth): FlexibleAuthContext, + Path(read_list_id): Path, +) -> Result { + auth.require_permission(&Permission::ReadListsRead)?; + let vis = user_visibility(&state, auth.user_id).await?; + let members = ReadListRepository::get_books(&state.db, read_list_id, vis.as_ref()) + .await + .map_err(internal("Failed to fetch read list books"))?; + let first = members + .first() + .ok_or_else(|| ApiError::NotFound("Read list has no visible books".to_string()))?; + Ok(Redirect::temporary(&format!( + "/api/v1/books/{}/thumbnail", + first.id + ))) +} + +/// List the read lists that contain a given book. +#[utoipa::path( + get, + path = "/api/v1/books/{book_id}/readlists", + responses( + (status = 200, description = "Read lists containing the book", body = ReadListListResponse), + (status = 403, description = "Forbidden"), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Read Lists" +)] +pub async fn get_book_readlists( + State(state): State>, + auth: AuthContext, + Path(book_id): Path, +) -> Result, ApiError> { + require_permission!(auth, Permission::ReadListsRead)?; + let vis = user_visibility(&state, auth.user_id).await?; + + let read_lists = ReadListRepository::get_read_lists_for_book(&state.db, book_id) + .await + .map_err(internal("Failed to fetch read lists for book"))?; + + let mut items = Vec::with_capacity(read_lists.len()); + for model in read_lists { + items.push(readlist_dto(&state.db, model, vis.as_ref()).await?); + } + let total = items.len(); + Ok(Json(ReadListListResponse { items, total })) +} diff --git a/crates/codex-api/src/routes/v1/routes/mod.rs b/crates/codex-api/src/routes/v1/routes/mod.rs index 5e88579f..13d9c099 100644 --- a/crates/codex-api/src/routes/v1/routes/mod.rs +++ b/crates/codex-api/src/routes/v1/routes/mod.rs @@ -12,6 +12,7 @@ mod misc; mod observability; mod oidc; mod plugins; +mod readlists; mod recommendations; mod releases; mod series; @@ -49,6 +50,7 @@ pub fn create_router(state: Arc) -> Router { .merge(observability::routes(state.clone())) .merge(want_to_read::routes(state.clone())) .merge(collections::routes(state.clone())) + .merge(readlists::routes(state.clone())) // Apply state to all routes .with_state(state) } diff --git a/crates/codex-api/src/routes/v1/routes/readlists.rs b/crates/codex-api/src/routes/v1/routes/readlists.rs new file mode 100644 index 00000000..f989a1c0 --- /dev/null +++ b/crates/codex-api/src/routes/v1/routes/readlists.rs @@ -0,0 +1,53 @@ +//! Read list routes +//! +//! Shared, ordered groupings of books across series. Reads are available to all +//! roles; create/modify/delete are gated by the read-list permissions. + +use super::super::handlers; +use crate::extractors::AppState; +use axum::{ + Router, + routing::{delete, get, patch, post, put}, +}; +use std::sync::Arc; + +/// Create read list routes. +pub fn routes(_state: Arc) -> Router> { + Router::new() + .route("/readlists", get(handlers::list_readlists)) + .route("/readlists", post(handlers::create_readlist)) + .route("/readlists/{read_list_id}", get(handlers::get_readlist)) + .route( + "/readlists/{read_list_id}", + patch(handlers::update_readlist), + ) + .route( + "/readlists/{read_list_id}", + delete(handlers::delete_readlist), + ) + .route( + "/readlists/{read_list_id}/books", + get(handlers::get_readlist_books), + ) + .route( + "/readlists/{read_list_id}/books", + post(handlers::add_readlist_books), + ) + .route( + "/readlists/{read_list_id}/books", + put(handlers::reorder_readlist_books), + ) + .route( + "/readlists/{read_list_id}/books/{book_id}", + delete(handlers::remove_readlist_book), + ) + .route( + "/readlists/{read_list_id}/thumbnail", + get(handlers::get_readlist_thumbnail), + ) + // Reverse lookup: read lists that contain a given book. + .route( + "/books/{book_id}/readlists", + get(handlers::get_book_readlists), + ) +} diff --git a/docs/api/openapi.json b/docs/api/openapi.json index eb89549a..32bb1962 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -5295,6 +5295,49 @@ ] } }, + "/api/v1/books/{book_id}/readlists": { + "get": { + "tags": [ + "Read Lists" + ], + "summary": "List the read lists that contain a given book.", + "operationId": "get_book_readlists", + "parameters": [ + { + "name": "book_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Read lists containing the book", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListListResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, "/api/v1/books/{book_id}/retry": { "post": { "tags": [ @@ -8695,6 +8738,461 @@ ] } }, + "/api/v1/readlists": { + "get": { + "tags": [ + "Read Lists" + ], + "summary": "List all read lists.", + "operationId": "list_readlists", + "responses": { + "200": { + "description": "Read lists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListListResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "post": { + "tags": [ + "Read Lists" + ], + "summary": "Create a read list.", + "operationId": "create_readlist", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateReadListRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListDto" + } + } + } + }, + "400": { + "description": "Invalid name" + }, + "403": { + "description": "Forbidden" + }, + "409": { + "description": "A read list with that name already exists" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/readlists/{read_list_id}": { + "get": { + "tags": [ + "Read Lists" + ], + "summary": "Get a read list.", + "operationId": "get_readlist", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Read list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListDto" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "delete": { + "tags": [ + "Read Lists" + ], + "summary": "Delete a read list.", + "operationId": "delete_readlist", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Deleted" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "patch": { + "tags": [ + "Read Lists" + ], + "summary": "Update a read list (rename / edit summary / toggle ordered).", + "operationId": "update_readlist", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateReadListRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListDto" + } + } + } + }, + "400": { + "description": "Invalid name" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "409": { + "description": "Name already in use" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/readlists/{read_list_id}/books": { + "get": { + "tags": [ + "Read Lists" + ], + "summary": "Get the books in a read list (visibility-filtered, in stored order).", + "operationId": "get_readlist_books", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Member books", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookDto" + } + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "put": { + "tags": [ + "Read Lists" + ], + "summary": "Set the manual order of a read list's books.", + "operationId": "reorder_readlist_books", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReorderReadListBooksRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Reordered" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "post": { + "tags": [ + "Read Lists" + ], + "summary": "Add one or more books to a read list.", + "operationId": "add_readlist_books", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddBooksToReadListRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Updated read list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListDto" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Read list or book not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/readlists/{read_list_id}/books/{book_id}": { + "delete": { + "tags": [ + "Read Lists" + ], + "summary": "Remove a book from a read list.", + "operationId": "remove_readlist_book", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "book_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Removed (or was not a member)" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/readlists/{read_list_id}/thumbnail": { + "get": { + "tags": [ + "Read Lists" + ], + "summary": "Get a read list's thumbnail (the first visible member book's cover).", + "operationId": "get_readlist_thumbnail", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "307": { + "description": "Redirect to the first member book thumbnail" + }, + "404": { + "description": "No visible member books" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, "/api/v1/release-sources": { "get": { "tags": [ @@ -20151,6 +20649,25 @@ } } }, + "AddBooksToReadListRequest": { + "type": "object", + "description": "Request to add one or more books to a read list.", + "required": [ + "bookIds" + ], + "properties": { + "bookIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "example": [ + "550e8400-e29b-41d4-a716-446655440001" + ] + } + } + }, "AddSeriesGenreRequest": { "type": "object", "description": "Request to add a single genre to a series", @@ -25325,6 +25842,30 @@ } } }, + "CreateReadListRequest": { + "type": "object", + "description": "Request to create a read list.", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "example": "Civil War" + }, + "ordered": { + "type": "boolean", + "description": "Defaults to `true` (manual reading order).", + "example": true + }, + "summary": { + "type": [ + "string", + "null" + ] + } + } + }, "CreateSeriesAliasRequest": { "type": "object", "required": [ @@ -36238,6 +36779,77 @@ } } }, + "ReadListDto": { + "type": "object", + "description": "A read list.", + "required": [ + "id", + "name", + "ordered", + "bookCount", + "createdAt", + "updatedAt" + ], + "properties": { + "bookCount": { + "type": "integer", + "format": "int64", + "description": "Number of member books visible to the requesting user.", + "example": 24, + "minimum": 0 + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "name": { + "type": "string", + "example": "Civil War" + }, + "ordered": { + "type": "boolean", + "description": "When true, members are kept in manual reading order; otherwise sorted by\nrelease date.", + "example": true + }, + "summary": { + "type": [ + "string", + "null" + ], + "description": "Optional description (Komga read lists carry a summary)." + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ReadListListResponse": { + "type": "object", + "description": "List of read lists.", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReadListDto" + } + }, + "total": { + "type": "integer", + "example": 3, + "minimum": 0 + } + } + }, "ReadProgressListResponse": { "type": "object", "description": "Response containing a list of reading progress records", @@ -37146,6 +37758,26 @@ } } }, + "ReorderReadListBooksRequest": { + "type": "object", + "description": "Request to set the manual order of a read list's books.", + "required": [ + "bookIds" + ], + "properties": { + "bookIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "example": [ + "550e8400-e29b-41d4-a716-446655440002", + "550e8400-e29b-41d4-a716-446655440001" + ] + } + } + }, "ReplaceBookMetadataRequest": { "type": "object", "description": "PUT request for full replacement of book metadata\n\nAll metadata fields will be replaced with the values in this request.\nOmitting a field (or setting it to null) will clear that field.", @@ -42836,6 +43468,31 @@ } } }, + "UpdateReadListRequest": { + "type": "object", + "description": "Request to update a read list. Absent fields are left unchanged. To clear the\nsummary, send `summary: null` explicitly.", + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "ordered": { + "type": [ + "boolean", + "null" + ] + }, + "summary": { + "type": [ + "string", + "null" + ], + "description": "`Some(Some(text))` sets it, `Some(None)` clears it, absent leaves it." + } + } + }, "UpdateReleaseLedgerEntryRequest": { "type": "object", "description": "PATCH payload for ledger row state transitions.\n\nOnly `state` is patchable from the API today; the rest of the row is\nsource-controlled. `state` is validated against the canonical set:\n`announced` | `dismissed` | `marked_acquired` | `hidden`.", diff --git a/tests/api/mod.rs b/tests/api/mod.rs index f9db6ba1..362f94f0 100644 --- a/tests/api/mod.rs +++ b/tests/api/mod.rs @@ -42,6 +42,7 @@ mod plugins; mod pool_contention; mod rate_limit; mod read_progress; +mod readlists; mod recommendations; mod refresh_token; mod releases; diff --git a/tests/api/readlists.rs b/tests/api/readlists.rs new file mode 100644 index 00000000..0cfa79f6 --- /dev/null +++ b/tests/api/readlists.rs @@ -0,0 +1,283 @@ +#[path = "../common/mod.rs"] +mod common; + +use codex::api::error::ErrorResponse; +use codex::api::routes::v1::dto::{BookDto, ReadListDto, ReadListListResponse}; +use codex::db::ScanningStrategy; +use codex::db::repositories::{ + BookRepository, LibraryRepository, SeriesRepository, UserRepository, +}; +use codex::utils::password; +use common::*; +use hyper::StatusCode; + +async fn user_and_token( + db: &sea_orm::DatabaseConnection, + state: &codex::api::extractors::AuthState, + username: &str, + is_admin: bool, +) -> (uuid::Uuid, String) { + let password_hash = password::hash_password("pw123456").unwrap(); + let user = create_test_user( + username, + &format!("{username}@example.com"), + &password_hash, + is_admin, + ); + let created = UserRepository::create(db, &user).await.unwrap(); + let token = state + .jwt_service + .generate_token(created.id, created.username.clone(), created.get_role()) + .unwrap(); + (created.id, token) +} + +/// Create a library, a series, and N books under it. +async fn make_books( + db: &sea_orm::DatabaseConnection, + count: usize, +) -> Vec { + use chrono::Utc; + let library = LibraryRepository::create(db, "Lib", "/test", ScanningStrategy::Default) + .await + .unwrap(); + let series = SeriesRepository::create(db, library.id, "Series", None) + .await + .unwrap(); + let mut out = Vec::new(); + for _ in 0..count { + let book = codex::db::entities::books::Model { + id: uuid::Uuid::new_v4(), + series_id: series.id, + library_id: library.id, + path: format!("/test/{}.cbz", uuid::Uuid::new_v4()), + file_name: "book.cbz".to_string(), + file_size: 1024, + file_hash: format!("hash_{}", uuid::Uuid::new_v4()), + partial_hash: String::new(), + format: "cbz".to_string(), + page_count: 10, + deleted: false, + analyzed: false, + analysis_error: None, + analysis_errors: None, + modified_at: Utc::now(), + created_at: Utc::now(), + updated_at: Utc::now(), + thumbnail_path: None, + thumbnail_generated_at: None, + koreader_hash: None, + epub_positions: None, + epub_spine_items: None, + }; + out.push(BookRepository::create(db, &book, None).await.unwrap()); + } + out +} + +#[tokio::test] +async fn test_create_with_summary_get_and_list() { + let (db, _t) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "admin", true).await; + let app = create_test_router(state).await; + + let req = post_json_request_with_auth( + "/api/v1/readlists", + &serde_json::json!({ "name": "Civil War", "summary": "Crossover event" }), + &token, + ); + let (status, created): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::CREATED); + let created = created.unwrap(); + assert_eq!(created.name, "Civil War"); + assert_eq!(created.summary.as_deref(), Some("Crossover event")); + // Read lists default to ordered = true. + assert!(created.ordered); + assert_eq!(created.book_count, 0); + + let req = get_request_with_auth(&format!("/api/v1/readlists/{}", created.id), &token); + let (status, fetched): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(fetched.unwrap().id, created.id); + + let req = get_request_with_auth("/api/v1/readlists", &token); + let (status, list): (StatusCode, Option) = + make_json_request(app, req).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(list.unwrap().total, 1); +} + +#[tokio::test] +async fn test_update_clears_summary() { + let (db, _t) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "admin", true).await; + let app = create_test_router(state).await; + + let req = post_json_request_with_auth( + "/api/v1/readlists", + &serde_json::json!({ "name": "List", "summary": "to be cleared" }), + &token, + ); + let (_s, created): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + let id = created.unwrap().id; + + // Explicit null clears the summary; ordered toggled off. + let req = patch_json_request_with_auth( + &format!("/api/v1/readlists/{id}"), + &serde_json::json!({ "summary": null, "ordered": false }), + &token, + ); + let (status, updated): (StatusCode, Option) = make_json_request(app, req).await; + assert_eq!(status, StatusCode::OK); + let updated = updated.unwrap(); + assert_eq!(updated.summary, None); + assert!(!updated.ordered); +} + +#[tokio::test] +async fn test_permission_matrix() { + let (db, _t) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_admin, admin_token) = user_and_token(&db, &state, "admin", true).await; + let (_reader, reader_token) = user_and_token(&db, &state, "reader", false).await; + let app = create_test_router(state).await; + + // Reader can list. + let req = get_request_with_auth("/api/v1/readlists", &reader_token); + let (status, _): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::OK); + + // Reader cannot create. + let req = post_json_request_with_auth( + "/api/v1/readlists", + &serde_json::json!({ "name": "Nope" }), + &reader_token, + ); + let (status, _): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + + // Admin can create and delete. + let req = post_json_request_with_auth( + "/api/v1/readlists", + &serde_json::json!({ "name": "Admin List" }), + &admin_token, + ); + let (status, created): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::CREATED); + let id = created.unwrap().id; + + let req = delete_request_with_auth(&format!("/api/v1/readlists/{id}"), &reader_token); + let (status, _): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::FORBIDDEN); + + let req = delete_request_with_auth(&format!("/api/v1/readlists/{id}"), &admin_token); + let (status, _): (StatusCode, Option) = make_json_request(app, req).await; + assert_eq!(status, StatusCode::NO_CONTENT); +} + +#[tokio::test] +async fn test_member_management_order_and_count() { + let (db, _t) = setup_test_db().await; + let books = make_books(&db, 3).await; + + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "admin", true).await; + let app = create_test_router(state).await; + + let req = post_json_request_with_auth( + "/api/v1/readlists", + &serde_json::json!({ "name": "List", "ordered": true }), + &token, + ); + let (_s, rl): (StatusCode, Option) = make_json_request(app.clone(), req).await; + let rl_id = rl.unwrap().id; + + // Add all three books. + let req = post_json_request_with_auth( + &format!("/api/v1/readlists/{rl_id}/books"), + &serde_json::json!({ "bookIds": books.iter().map(|b| b.id).collect::>() }), + &token, + ); + let (status, updated): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(updated.unwrap().book_count, 3); + + // Members in insertion order. + let req = get_request_with_auth(&format!("/api/v1/readlists/{rl_id}/books"), &token); + let (status, members): (StatusCode, Option>) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::OK); + let members = members.unwrap(); + assert_eq!(members.len(), 3); + assert_eq!(members[0].id, books[0].id); + + // Reorder reversed. + let reversed: Vec<_> = books.iter().rev().map(|b| b.id).collect(); + let req = put_json_request_with_auth( + &format!("/api/v1/readlists/{rl_id}/books"), + &serde_json::json!({ "bookIds": reversed }), + &token, + ); + let (status, _): (StatusCode, Option) = make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::NO_CONTENT); + + let req = get_request_with_auth(&format!("/api/v1/readlists/{rl_id}/books"), &token); + let (_s, members): (StatusCode, Option>) = + make_json_request(app.clone(), req).await; + assert_eq!(members.unwrap()[0].id, books[2].id); + + // Remove the middle book. + let req = delete_request_with_auth( + &format!("/api/v1/readlists/{rl_id}/books/{}", books[1].id), + &token, + ); + let (status, _): (StatusCode, Option) = make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::NO_CONTENT); + + // books/{id}/readlists reverse lookup. + let req = get_request_with_auth(&format!("/api/v1/books/{}/readlists", books[0].id), &token); + let (status, containers): (StatusCode, Option) = + make_json_request(app, req).await; + assert_eq!(status, StatusCode::OK); + let containers = containers.unwrap(); + assert_eq!(containers.total, 1); + assert_eq!(containers.items[0].id, rl_id); +} + +#[tokio::test] +async fn test_add_nonexistent_book_and_duplicate_name() { + let (db, _t) = setup_test_db().await; + let state = create_test_auth_state(db.clone()).await; + let (_uid, token) = user_and_token(&db, &state, "admin", true).await; + let app = create_test_router(state).await; + + let body = serde_json::json!({ "name": "Dupe" }); + let req = post_json_request_with_auth("/api/v1/readlists", &body, &token); + let (_s, rl): (StatusCode, Option) = make_json_request(app.clone(), req).await; + let rl_id = rl.unwrap().id; + + // Duplicate name -> 409. + let req = post_json_request_with_auth("/api/v1/readlists", &body, &token); + let (status, _): (StatusCode, Option) = + make_json_request(app.clone(), req).await; + assert_eq!(status, StatusCode::CONFLICT); + + // Adding an unknown book -> 404. + let req = post_json_request_with_auth( + &format!("/api/v1/readlists/{rl_id}/books"), + &serde_json::json!({ "bookIds": [uuid::Uuid::new_v4()] }), + &token, + ); + let (status, _): (StatusCode, Option) = make_json_request(app, req).await; + assert_eq!(status, StatusCode::NOT_FOUND); +} diff --git a/web/openapi.json b/web/openapi.json index eb89549a..32bb1962 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -5295,6 +5295,49 @@ ] } }, + "/api/v1/books/{book_id}/readlists": { + "get": { + "tags": [ + "Read Lists" + ], + "summary": "List the read lists that contain a given book.", + "operationId": "get_book_readlists", + "parameters": [ + { + "name": "book_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Read lists containing the book", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListListResponse" + } + } + } + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, "/api/v1/books/{book_id}/retry": { "post": { "tags": [ @@ -8695,6 +8738,461 @@ ] } }, + "/api/v1/readlists": { + "get": { + "tags": [ + "Read Lists" + ], + "summary": "List all read lists.", + "operationId": "list_readlists", + "responses": { + "200": { + "description": "Read lists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListListResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "post": { + "tags": [ + "Read Lists" + ], + "summary": "Create a read list.", + "operationId": "create_readlist", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateReadListRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListDto" + } + } + } + }, + "400": { + "description": "Invalid name" + }, + "403": { + "description": "Forbidden" + }, + "409": { + "description": "A read list with that name already exists" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/readlists/{read_list_id}": { + "get": { + "tags": [ + "Read Lists" + ], + "summary": "Get a read list.", + "operationId": "get_readlist", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Read list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListDto" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "delete": { + "tags": [ + "Read Lists" + ], + "summary": "Delete a read list.", + "operationId": "delete_readlist", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Deleted" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "patch": { + "tags": [ + "Read Lists" + ], + "summary": "Update a read list (rename / edit summary / toggle ordered).", + "operationId": "update_readlist", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateReadListRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListDto" + } + } + } + }, + "400": { + "description": "Invalid name" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + }, + "409": { + "description": "Name already in use" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/readlists/{read_list_id}/books": { + "get": { + "tags": [ + "Read Lists" + ], + "summary": "Get the books in a read list (visibility-filtered, in stored order).", + "operationId": "get_readlist_books", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Member books", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BookDto" + } + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "put": { + "tags": [ + "Read Lists" + ], + "summary": "Set the manual order of a read list's books.", + "operationId": "reorder_readlist_books", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReorderReadListBooksRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Reordered" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + }, + "post": { + "tags": [ + "Read Lists" + ], + "summary": "Add one or more books to a read list.", + "operationId": "add_readlist_books", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddBooksToReadListRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Updated read list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadListDto" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Read list or book not found" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/readlists/{read_list_id}/books/{book_id}": { + "delete": { + "tags": [ + "Read Lists" + ], + "summary": "Remove a book from a read list.", + "operationId": "remove_readlist_book", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "book_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Removed (or was not a member)" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, + "/api/v1/readlists/{read_list_id}/thumbnail": { + "get": { + "tags": [ + "Read Lists" + ], + "summary": "Get a read list's thumbnail (the first visible member book's cover).", + "operationId": "get_readlist_thumbnail", + "parameters": [ + { + "name": "read_list_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "307": { + "description": "Redirect to the first member book thumbnail" + }, + "404": { + "description": "No visible member books" + } + }, + "security": [ + { + "bearer_auth": [] + }, + { + "api_key": [] + } + ] + } + }, "/api/v1/release-sources": { "get": { "tags": [ @@ -20151,6 +20649,25 @@ } } }, + "AddBooksToReadListRequest": { + "type": "object", + "description": "Request to add one or more books to a read list.", + "required": [ + "bookIds" + ], + "properties": { + "bookIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "example": [ + "550e8400-e29b-41d4-a716-446655440001" + ] + } + } + }, "AddSeriesGenreRequest": { "type": "object", "description": "Request to add a single genre to a series", @@ -25325,6 +25842,30 @@ } } }, + "CreateReadListRequest": { + "type": "object", + "description": "Request to create a read list.", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "example": "Civil War" + }, + "ordered": { + "type": "boolean", + "description": "Defaults to `true` (manual reading order).", + "example": true + }, + "summary": { + "type": [ + "string", + "null" + ] + } + } + }, "CreateSeriesAliasRequest": { "type": "object", "required": [ @@ -36238,6 +36779,77 @@ } } }, + "ReadListDto": { + "type": "object", + "description": "A read list.", + "required": [ + "id", + "name", + "ordered", + "bookCount", + "createdAt", + "updatedAt" + ], + "properties": { + "bookCount": { + "type": "integer", + "format": "int64", + "description": "Number of member books visible to the requesting user.", + "example": 24, + "minimum": 0 + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string", + "format": "uuid", + "example": "550e8400-e29b-41d4-a716-446655440000" + }, + "name": { + "type": "string", + "example": "Civil War" + }, + "ordered": { + "type": "boolean", + "description": "When true, members are kept in manual reading order; otherwise sorted by\nrelease date.", + "example": true + }, + "summary": { + "type": [ + "string", + "null" + ], + "description": "Optional description (Komga read lists carry a summary)." + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ReadListListResponse": { + "type": "object", + "description": "List of read lists.", + "required": [ + "items", + "total" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReadListDto" + } + }, + "total": { + "type": "integer", + "example": 3, + "minimum": 0 + } + } + }, "ReadProgressListResponse": { "type": "object", "description": "Response containing a list of reading progress records", @@ -37146,6 +37758,26 @@ } } }, + "ReorderReadListBooksRequest": { + "type": "object", + "description": "Request to set the manual order of a read list's books.", + "required": [ + "bookIds" + ], + "properties": { + "bookIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "example": [ + "550e8400-e29b-41d4-a716-446655440002", + "550e8400-e29b-41d4-a716-446655440001" + ] + } + } + }, "ReplaceBookMetadataRequest": { "type": "object", "description": "PUT request for full replacement of book metadata\n\nAll metadata fields will be replaced with the values in this request.\nOmitting a field (or setting it to null) will clear that field.", @@ -42836,6 +43468,31 @@ } } }, + "UpdateReadListRequest": { + "type": "object", + "description": "Request to update a read list. Absent fields are left unchanged. To clear the\nsummary, send `summary: null` explicitly.", + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "ordered": { + "type": [ + "boolean", + "null" + ] + }, + "summary": { + "type": [ + "string", + "null" + ], + "description": "`Some(Some(text))` sets it, `Some(None)` clears it, absent leaves it." + } + } + }, "UpdateReleaseLedgerEntryRequest": { "type": "object", "description": "PATCH payload for ledger row state transitions.\n\nOnly `state` is patchable from the API today; the rest of the row is\nsource-controlled. `state` is validated against the canonical set:\n`announced` | `dismissed` | `marked_acquired` | `hidden`.", diff --git a/web/src/types/api.generated.ts b/web/src/types/api.generated.ts index d056e026..54fd751f 100644 --- a/web/src/types/api.generated.ts +++ b/web/src/types/api.generated.ts @@ -1712,6 +1712,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/books/{book_id}/readlists": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List the read lists that contain a given book. */ + get: operations["get_book_readlists"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/books/{book_id}/retry": { parameters: { query?: never; @@ -2876,6 +2893,96 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/readlists": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List all read lists. */ + get: operations["list_readlists"]; + put?: never; + /** Create a read list. */ + post: operations["create_readlist"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/readlists/{read_list_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a read list. */ + get: operations["get_readlist"]; + put?: never; + post?: never; + /** Delete a read list. */ + delete: operations["delete_readlist"]; + options?: never; + head?: never; + /** Update a read list (rename / edit summary / toggle ordered). */ + patch: operations["update_readlist"]; + trace?: never; + }; + "/api/v1/readlists/{read_list_id}/books": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get the books in a read list (visibility-filtered, in stored order). */ + get: operations["get_readlist_books"]; + /** Set the manual order of a read list's books. */ + put: operations["reorder_readlist_books"]; + /** Add one or more books to a read list. */ + post: operations["add_readlist_books"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/readlists/{read_list_id}/books/{book_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Remove a book from a read list. */ + delete: operations["remove_readlist_book"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/readlists/{read_list_id}/thumbnail": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a read list's thumbnail (the first visible member book's cover). */ + get: operations["get_readlist_thumbnail"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/release-sources": { parameters: { query?: never; @@ -7272,6 +7379,15 @@ export interface components { */ oidcGroupName: string; }; + /** @description Request to add one or more books to a read list. */ + AddBooksToReadListRequest: { + /** + * @example [ + * "550e8400-e29b-41d4-a716-446655440001" + * ] + */ + bookIds: string[]; + }; /** @description Request to add a single genre to a series */ AddSeriesGenreRequest: { /** @@ -10258,6 +10374,17 @@ export interface components { /** @description Working directory for the plugin process */ workingDirectory?: string | null; }; + /** @description Request to create a read list. */ + CreateReadListRequest: { + /** @example Civil War */ + name: string; + /** + * @description Defaults to `true` (manual reading order). + * @example true + */ + ordered?: boolean; + summary?: string | null; + }; CreateSeriesAliasRequest: { /** * @description Alias text. Will be trimmed; must normalize to non-empty. @@ -16043,6 +16170,40 @@ export interface components { */ staleCount: number; }; + /** @description A read list. */ + ReadListDto: { + /** + * Format: int64 + * @description Number of member books visible to the requesting user. + * @example 24 + */ + bookCount: number; + /** Format: date-time */ + createdAt: string; + /** + * Format: uuid + * @example 550e8400-e29b-41d4-a716-446655440000 + */ + id: string; + /** @example Civil War */ + name: string; + /** + * @description When true, members are kept in manual reading order; otherwise sorted by + * release date. + * @example true + */ + ordered: boolean; + /** @description Optional description (Komga read lists carry a summary). */ + summary?: string | null; + /** Format: date-time */ + updatedAt: string; + }; + /** @description List of read lists. */ + ReadListListResponse: { + items: components["schemas"]["ReadListDto"][]; + /** @example 3 */ + total: number; + }; /** @description Response containing a list of reading progress records */ ReadProgressListResponse: { /** @description List of progress records */ @@ -16554,6 +16715,16 @@ export interface components { */ seriesIds: string[]; }; + /** @description Request to set the manual order of a read list's books. */ + ReorderReadListBooksRequest: { + /** + * @example [ + * "550e8400-e29b-41d4-a716-446655440002", + * "550e8400-e29b-41d4-a716-446655440001" + * ] + */ + bookIds: string[]; + }; /** * @description PUT request for full replacement of book metadata * @@ -19666,6 +19837,16 @@ export interface components { */ progressPercentage?: number | null; }; + /** + * @description Request to update a read list. Absent fields are left unchanged. To clear the + * summary, send `summary: null` explicitly. + */ + UpdateReadListRequest: { + name?: string | null; + ordered?: boolean | null; + /** @description `Some(Some(text))` sets it, `Some(None)` clears it, absent leaves it. */ + summary?: string | null; + }; /** * @description PATCH payload for ledger row state transitions. * @@ -24291,6 +24472,35 @@ export interface operations { }; }; }; + get_book_readlists: { + parameters: { + query?: never; + header?: never; + path: { + book_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Read lists containing the book */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReadListListResponse"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; retry_book_errors: { parameters: { query?: never; @@ -26820,6 +27030,378 @@ export interface operations { }; }; }; + list_readlists: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Read lists */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReadListListResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + create_readlist: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateReadListRequest"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReadListDto"]; + }; + }; + /** @description Invalid name */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description A read list with that name already exists */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + get_readlist: { + parameters: { + query?: never; + header?: never; + path: { + read_list_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Read list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReadListDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete_readlist: { + parameters: { + query?: never; + header?: never; + path: { + read_list_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deleted */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + update_readlist: { + parameters: { + query?: never; + header?: never; + path: { + read_list_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateReadListRequest"]; + }; + }; + responses: { + /** @description Updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReadListDto"]; + }; + }; + /** @description Invalid name */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Name already in use */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + get_readlist_books: { + parameters: { + query?: never; + header?: never; + path: { + read_list_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Member books */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BookDto"][]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + reorder_readlist_books: { + parameters: { + query?: never; + header?: never; + path: { + read_list_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ReorderReadListBooksRequest"]; + }; + }; + responses: { + /** @description Reordered */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + add_readlist_books: { + parameters: { + query?: never; + header?: never; + path: { + read_list_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AddBooksToReadListRequest"]; + }; + }; + responses: { + /** @description Updated read list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReadListDto"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Read list or book not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + remove_readlist_book: { + parameters: { + query?: never; + header?: never; + path: { + read_list_id: string; + book_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Removed (or was not a member) */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + get_readlist_thumbnail: { + parameters: { + query?: never; + header?: never; + path: { + read_list_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Redirect to the first member book thumbnail */ + 307: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description No visible member books */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; list_release_sources: { parameters: { query?: never; From bbce98e18d9996240ef008803f8c1ef9b3b5425b Mon Sep 17 00:00:00 2001 From: Sylvain Cau Date: Mon, 15 Jun 2026 18:30:34 -0700 Subject: [PATCH 08/13] feat(web): add read list pages and management Add the frontend for shared read lists of books: - A read lists list page with cover thumbnails and a gated "New read list" modal that captures a name, summary, and reading-order flag - A read list detail page showing the summary and member books, with per-member removal and up/down reordering for ordered lists, plus gated edit and delete - An "Add to read list" menu on the book page that toggles membership across read lists and can create new ones inline - A sidebar link and routes, the API client, and query hooks Controls that create, modify, or delete only render for users who can manage read lists. Covered by a component test. --- web/src/App.tsx | 24 ++ web/src/api/readlists.ts | 71 +++++ web/src/components/layout/Sidebar.tsx | 9 + .../readlists/AddToReadListButton.tsx | 92 +++++++ .../readlists/ReadListFormModal.test.tsx | 50 ++++ .../readlists/ReadListFormModal.tsx | 109 ++++++++ web/src/hooks/useReadLists.ts | 155 +++++++++++ web/src/pages/BookDetail.tsx | 5 + web/src/pages/ReadListDetail.tsx | 242 ++++++++++++++++++ web/src/pages/ReadLists.tsx | 116 +++++++++ 10 files changed, 873 insertions(+) create mode 100644 web/src/api/readlists.ts create mode 100644 web/src/components/readlists/AddToReadListButton.tsx create mode 100644 web/src/components/readlists/ReadListFormModal.test.tsx create mode 100644 web/src/components/readlists/ReadListFormModal.tsx create mode 100644 web/src/hooks/useReadLists.ts create mode 100644 web/src/pages/ReadListDetail.tsx create mode 100644 web/src/pages/ReadLists.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 740b4283..9b3e5591 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -22,6 +22,8 @@ import { LibraryJobsPage } from "@/pages/LibraryJobs"; import { Login } from "@/pages/Login"; import { OidcComplete } from "@/pages/OidcComplete"; import { Reader } from "@/pages/Reader"; +import { ReadListDetail } from "@/pages/ReadListDetail"; +import { ReadLists } from "@/pages/ReadLists"; import { Recommendations } from "@/pages/Recommendations"; import { Register } from "@/pages/Register"; import { ReleasesInbox } from "@/pages/ReleasesInbox"; @@ -238,6 +240,28 @@ function App() { } /> + + + + + + } + /> + + + + + + + } + /> + => { + const response = await api.get("/readlists"); + return response.data.items; + }, + + get: async (id: string): Promise => { + const response = await api.get(`/readlists/${id}`); + return response.data; + }, + + /** Member books, in stored order, filtered by the user's visibility. */ + getBooks: async (id: string): Promise => { + const response = await api.get(`/readlists/${id}/books`); + return response.data; + }, + + create: async (body: CreateReadListRequest): Promise => { + const response = await api.post("/readlists", body); + return response.data; + }, + + update: async ( + id: string, + body: UpdateReadListRequest, + ): Promise => { + const response = await api.patch(`/readlists/${id}`, body); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`/readlists/${id}`); + }, + + addBooks: async (id: string, bookIds: string[]): Promise => { + const response = await api.post(`/readlists/${id}/books`, { + bookIds, + }); + return response.data; + }, + + removeBook: async (id: string, bookId: string): Promise => { + await api.delete(`/readlists/${id}/books/${bookId}`); + }, + + /** Set the full manual reading order of a read list's books. */ + reorder: async (id: string, bookIds: string[]): Promise => { + await api.put(`/readlists/${id}/books`, { bookIds }); + }, + + /** Read lists that contain a given book. */ + forBook: async (bookId: string): Promise => { + const response = await api.get( + `/books/${bookId}/readlists`, + ); + return response.data.items; + }, +}; diff --git a/web/src/components/layout/Sidebar.tsx b/web/src/components/layout/Sidebar.tsx index 75e95667..4309bab0 100644 --- a/web/src/components/layout/Sidebar.tsx +++ b/web/src/components/layout/Sidebar.tsx @@ -29,6 +29,7 @@ import { IconHome, IconLayoutGrid, IconLink, + IconList, IconLogout, IconPhoto, IconPlugConnected, @@ -414,6 +415,14 @@ export function Sidebar({ onNavigate }: SidebarProps = {}) { active={currentPath.startsWith("/collections")} onClick={onNavigate} /> + } + active={currentPath.startsWith("/readlists")} + onClick={onNavigate} + /> {hasRecommendationPlugin && ( r.id)); + const busy = add.isPending || remove.isPending; + + const toggle = (readListId: string) => { + if (memberIds.has(readListId)) { + remove.mutate({ readListId, bookId }); + } else { + add.mutate({ readListId, bookIds: [bookId] }); + } + }; + + return ( + <> + + + + + + + + + + Read lists + {isLoading ? ( + + + + ) : !readLists || readLists.length === 0 ? ( + No read lists yet + ) : ( + readLists.map((r) => ( + + ) : ( + + ) + } + onClick={() => toggle(r.id)} + > + {r.name} + + )) + )} + + } + onClick={() => setCreateOpen(true)} + > + New read list… + + + + + setCreateOpen(false)} + onCreated={(r) => add.mutate({ readListId: r.id, bookIds: [bookId] })} + /> + + ); +} diff --git a/web/src/components/readlists/ReadListFormModal.test.tsx b/web/src/components/readlists/ReadListFormModal.test.tsx new file mode 100644 index 00000000..73dbfcc0 --- /dev/null +++ b/web/src/components/readlists/ReadListFormModal.test.tsx @@ -0,0 +1,50 @@ +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { readListsApi } from "@/api/readlists"; +import { renderWithProviders, screen, waitFor } from "@/test/utils"; +import { ReadListFormModal } from "./ReadListFormModal"; + +vi.mock("@/api/readlists", () => ({ + readListsApi: { + create: vi.fn().mockResolvedValue({ + id: "r1", + name: "Civil War", + summary: "Crossover", + ordered: true, + bookCount: 0, + createdAt: "2026-06-15T00:00:00Z", + updatedAt: "2026-06-15T00:00:00Z", + }), + update: vi.fn(), + }, +})); + +describe("ReadListFormModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates a read list with name, summary, and ordered flag", async () => { + const user = userEvent.setup(); + const onCreated = vi.fn(); + renderWithProviders( + , + ); + + await user.type(screen.getByPlaceholderText("e.g. Civil War"), "Civil War"); + await user.type( + screen.getByPlaceholderText("Optional description"), + "Crossover", + ); + await user.click(screen.getByRole("button", { name: /create/i })); + + await waitFor(() => + expect(readListsApi.create).toHaveBeenCalledWith({ + name: "Civil War", + summary: "Crossover", + ordered: true, + }), + ); + await waitFor(() => expect(onCreated).toHaveBeenCalled()); + }); +}); diff --git a/web/src/components/readlists/ReadListFormModal.tsx b/web/src/components/readlists/ReadListFormModal.tsx new file mode 100644 index 00000000..6242429d --- /dev/null +++ b/web/src/components/readlists/ReadListFormModal.tsx @@ -0,0 +1,109 @@ +import { + Button, + Checkbox, + Group, + Modal, + Stack, + Textarea, + TextInput, +} from "@mantine/core"; +import { useEffect, useState } from "react"; +import type { ReadList } from "@/api/readlists"; +import { useCreateReadList, useUpdateReadList } from "@/hooks/useReadLists"; + +interface ReadListFormModalProps { + opened: boolean; + onClose: () => void; + /** When provided, the modal edits this read list instead of creating one. */ + readList?: ReadList; + /** Called with the created read list (create mode only). */ + onCreated?: (readList: ReadList) => void; +} + +export function ReadListFormModal({ + opened, + onClose, + readList, + onCreated, +}: ReadListFormModalProps) { + const isEdit = Boolean(readList); + const [name, setName] = useState(""); + const [summary, setSummary] = useState(""); + const [ordered, setOrdered] = useState(true); + + useEffect(() => { + if (opened) { + setName(readList?.name ?? ""); + setSummary(readList?.summary ?? ""); + setOrdered(readList?.ordered ?? true); + } + }, [opened, readList]); + + const createMutation = useCreateReadList(); + const updateMutation = useUpdateReadList(readList?.id ?? ""); + const pending = createMutation.isPending || updateMutation.isPending; + + const submit = () => { + const trimmedName = name.trim(); + if (!trimmedName) return; + const trimmedSummary = summary.trim(); + if (isEdit) { + updateMutation.mutate( + { name: trimmedName, summary: trimmedSummary || null, ordered }, + { onSuccess: () => onClose() }, + ); + } else { + createMutation.mutate( + { name: trimmedName, summary: trimmedSummary || undefined, ordered }, + { + onSuccess: (created) => { + onCreated?.(created); + onClose(); + }, + }, + ); + } + }; + + return ( + + + setName(e.currentTarget.value)} + data-autofocus + required + /> +