From 51a33e61e8868b4584f7c6b5d1ffd8ea461f12fc Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 23 Feb 2026 02:16:27 +0200 Subject: [PATCH 01/17] chore: add .worktrees/ to .gitignore Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8014622..c8f56ec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ docs/site/ NOTES.md -firebase-debug.log \ No newline at end of file +firebase-debug.log +.worktrees/ \ No newline at end of file From 25d338745fdea6fa00ba9ce1c25c503cce95cc5b Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 23 Feb 2026 02:22:43 +0200 Subject: [PATCH 02/17] chore(server): initialize Alembic for database migrations Co-Authored-By: Claude Opus 4.6 --- server/alembic.ini | 149 ++++++++++++++++++++++++++++++++++ server/alembic/README | 1 + server/alembic/env.py | 52 ++++++++++++ server/alembic/script.py.mako | 28 +++++++ 4 files changed, 230 insertions(+) create mode 100644 server/alembic.ini create mode 100644 server/alembic/README create mode 100644 server/alembic/env.py create mode 100644 server/alembic/script.py.mako diff --git a/server/alembic.ini b/server/alembic.ini new file mode 100644 index 0000000..f0da7e5 --- /dev/null +++ b/server/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/server/alembic/README b/server/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/server/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/server/alembic/env.py b/server/alembic/env.py new file mode 100644 index 0000000..49da7f5 --- /dev/null +++ b/server/alembic/env.py @@ -0,0 +1,52 @@ +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import async_engine_from_config + +from papyrus.config import get_settings +from papyrus.core.database import Base + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + +settings = get_settings() +config.set_main_option("sqlalchemy.url", settings.database_url) + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection): + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/server/alembic/script.py.mako b/server/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/server/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} From 8a0c5cb3350e0cba4eb6eff59aef7b5efd1084f4 Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 23 Feb 2026 02:25:36 +0200 Subject: [PATCH 03/17] feat(server): add SQLAlchemy ORM models for community features Co-Authored-By: Claude Opus 4.6 --- server/src/papyrus/models/__init__.py | 23 +++++++++ server/src/papyrus/models/activity.py | 31 ++++++++++++ server/src/papyrus/models/block.py | 23 +++++++++ server/src/papyrus/models/catalog_book.py | 32 ++++++++++++ server/src/papyrus/models/follow.py | 23 +++++++++ server/src/papyrus/models/rating.py | 27 +++++++++++ server/src/papyrus/models/review.py | 39 +++++++++++++++ server/src/papyrus/models/review_reaction.py | 30 ++++++++++++ server/src/papyrus/models/user.py | 38 +++++++++++++++ server/src/papyrus/models/user_book.py | 51 ++++++++++++++++++++ 10 files changed, 317 insertions(+) create mode 100644 server/src/papyrus/models/__init__.py create mode 100644 server/src/papyrus/models/activity.py create mode 100644 server/src/papyrus/models/block.py create mode 100644 server/src/papyrus/models/catalog_book.py create mode 100644 server/src/papyrus/models/follow.py create mode 100644 server/src/papyrus/models/rating.py create mode 100644 server/src/papyrus/models/review.py create mode 100644 server/src/papyrus/models/review_reaction.py create mode 100644 server/src/papyrus/models/user.py create mode 100644 server/src/papyrus/models/user_book.py diff --git a/server/src/papyrus/models/__init__.py b/server/src/papyrus/models/__init__.py new file mode 100644 index 0000000..ee4712c --- /dev/null +++ b/server/src/papyrus/models/__init__.py @@ -0,0 +1,23 @@ +"""SQLAlchemy ORM models.""" + +from papyrus.models.activity import Activity +from papyrus.models.block import Block +from papyrus.models.catalog_book import CatalogBook +from papyrus.models.follow import Follow +from papyrus.models.rating import Rating +from papyrus.models.review import Review +from papyrus.models.review_reaction import ReviewReaction +from papyrus.models.user import User +from papyrus.models.user_book import UserBook + +__all__ = [ + "Activity", + "Block", + "CatalogBook", + "Follow", + "Rating", + "Review", + "ReviewReaction", + "User", + "UserBook", +] diff --git a/server/src/papyrus/models/activity.py b/server/src/papyrus/models/activity.py new file mode 100644 index 0000000..e234d3e --- /dev/null +++ b/server/src/papyrus/models/activity.py @@ -0,0 +1,31 @@ +"""Activity feed model.""" + +from datetime import datetime +from uuid import UUID + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from papyrus.core.database import Base +from papyrus.models.user_book import BookVisibility + + +class Activity(Base): + __tablename__ = "activities" + + id: Mapped[UUID] = mapped_column( + sa.Uuid, primary_key=True, server_default=sa.text("gen_random_uuid()") + ) + user_id: Mapped[UUID] = mapped_column( + sa.Uuid, sa.ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + activity_type: Mapped[str] = mapped_column(sa.String(50)) + target_type: Mapped[str | None] = mapped_column(sa.String(50)) + target_id: Mapped[UUID | None] = mapped_column(sa.Uuid) + metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB) + visibility: Mapped[BookVisibility] = mapped_column( + sa.Enum(BookVisibility, name="book_visibility", create_type=False), + server_default=BookVisibility.PUBLIC.value, + ) + created_at: Mapped[datetime] = mapped_column(server_default=sa.func.now(), index=True) diff --git a/server/src/papyrus/models/block.py b/server/src/papyrus/models/block.py new file mode 100644 index 0000000..13601c4 --- /dev/null +++ b/server/src/papyrus/models/block.py @@ -0,0 +1,23 @@ +"""Block relationship model.""" + +from datetime import datetime +from uuid import UUID + +import sqlalchemy as sa +from sqlalchemy.orm import Mapped, mapped_column + +from papyrus.core.database import Base + + +class Block(Base): + __tablename__ = "blocks" + + blocker_id: Mapped[UUID] = mapped_column( + sa.Uuid, sa.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True + ) + blocked_id: Mapped[UUID] = mapped_column( + sa.Uuid, sa.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True + ) + created_at: Mapped[datetime] = mapped_column(server_default=sa.func.now()) + + __table_args__ = (sa.CheckConstraint("blocker_id != blocked_id", name="no_self_block"),) diff --git a/server/src/papyrus/models/catalog_book.py b/server/src/papyrus/models/catalog_book.py new file mode 100644 index 0000000..cbff097 --- /dev/null +++ b/server/src/papyrus/models/catalog_book.py @@ -0,0 +1,32 @@ +"""Community book catalog model.""" + +from datetime import date, datetime +from uuid import UUID + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from papyrus.core.database import Base + + +class CatalogBook(Base): + __tablename__ = "catalog_books" + + id: Mapped[UUID] = mapped_column( + sa.Uuid, primary_key=True, server_default=sa.text("gen_random_uuid()") + ) + open_library_id: Mapped[str | None] = mapped_column(sa.String(50), unique=True) + isbn: Mapped[str | None] = mapped_column(sa.String(13), index=True) + title: Mapped[str] = mapped_column(sa.String(500)) + authors: Mapped[dict | None] = mapped_column(JSONB) + cover_url: Mapped[str | None] = mapped_column(sa.String(500)) + description: Mapped[str | None] = mapped_column(sa.Text) + page_count: Mapped[int | None] = mapped_column() + published_date: Mapped[date | None] = mapped_column() + genres: Mapped[dict | None] = mapped_column(JSONB) + added_by_user_id: Mapped[UUID | None] = mapped_column( + sa.Uuid, sa.ForeignKey("users.id", ondelete="SET NULL") + ) + verified: Mapped[bool] = mapped_column(server_default=sa.text("false")) + created_at: Mapped[datetime] = mapped_column(server_default=sa.func.now()) diff --git a/server/src/papyrus/models/follow.py b/server/src/papyrus/models/follow.py new file mode 100644 index 0000000..b9c422d --- /dev/null +++ b/server/src/papyrus/models/follow.py @@ -0,0 +1,23 @@ +"""Follow relationship model.""" + +from datetime import datetime +from uuid import UUID + +import sqlalchemy as sa +from sqlalchemy.orm import Mapped, mapped_column + +from papyrus.core.database import Base + + +class Follow(Base): + __tablename__ = "follows" + + follower_id: Mapped[UUID] = mapped_column( + sa.Uuid, sa.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True + ) + followed_id: Mapped[UUID] = mapped_column( + sa.Uuid, sa.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True + ) + created_at: Mapped[datetime] = mapped_column(server_default=sa.func.now()) + + __table_args__ = (sa.CheckConstraint("follower_id != followed_id", name="no_self_follow"),) diff --git a/server/src/papyrus/models/rating.py b/server/src/papyrus/models/rating.py new file mode 100644 index 0000000..9424964 --- /dev/null +++ b/server/src/papyrus/models/rating.py @@ -0,0 +1,27 @@ +"""Book rating model (1-10 scale).""" + +from datetime import datetime +from uuid import UUID + +import sqlalchemy as sa +from sqlalchemy.orm import Mapped, mapped_column + +from papyrus.core.database import Base + + +class Rating(Base): + __tablename__ = "ratings" + + user_id: Mapped[UUID] = mapped_column( + sa.Uuid, sa.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True + ) + catalog_book_id: Mapped[UUID] = mapped_column( + sa.Uuid, sa.ForeignKey("catalog_books.id", ondelete="CASCADE"), primary_key=True + ) + score: Mapped[int] = mapped_column(sa.SmallInteger) + created_at: Mapped[datetime] = mapped_column(server_default=sa.func.now()) + updated_at: Mapped[datetime] = mapped_column( + server_default=sa.func.now(), onupdate=sa.func.now() + ) + + __table_args__ = (sa.CheckConstraint("score >= 1 AND score <= 10", name="rating_range"),) diff --git a/server/src/papyrus/models/review.py b/server/src/papyrus/models/review.py new file mode 100644 index 0000000..db24243 --- /dev/null +++ b/server/src/papyrus/models/review.py @@ -0,0 +1,39 @@ +"""Book review model.""" + +from datetime import datetime +from uuid import UUID + +import sqlalchemy as sa +from sqlalchemy.orm import Mapped, mapped_column + +from papyrus.core.database import Base +from papyrus.models.user_book import BookVisibility + + +class Review(Base): + __tablename__ = "reviews" + + id: Mapped[UUID] = mapped_column( + sa.Uuid, primary_key=True, server_default=sa.text("gen_random_uuid()") + ) + user_id: Mapped[UUID] = mapped_column( + sa.Uuid, sa.ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + catalog_book_id: Mapped[UUID] = mapped_column( + sa.Uuid, sa.ForeignKey("catalog_books.id", ondelete="CASCADE"), index=True + ) + title: Mapped[str | None] = mapped_column(sa.String(255)) + body: Mapped[str] = mapped_column(sa.Text) + contains_spoilers: Mapped[bool] = mapped_column(server_default=sa.text("false")) + visibility: Mapped[BookVisibility] = mapped_column( + sa.Enum(BookVisibility, name="book_visibility", create_type=False), + server_default=BookVisibility.PUBLIC.value, + ) + created_at: Mapped[datetime] = mapped_column(server_default=sa.func.now()) + updated_at: Mapped[datetime] = mapped_column( + server_default=sa.func.now(), onupdate=sa.func.now() + ) + + __table_args__ = ( + sa.UniqueConstraint("user_id", "catalog_book_id", name="one_review_per_book"), + ) diff --git a/server/src/papyrus/models/review_reaction.py b/server/src/papyrus/models/review_reaction.py new file mode 100644 index 0000000..7cbc74f --- /dev/null +++ b/server/src/papyrus/models/review_reaction.py @@ -0,0 +1,30 @@ +"""Review reaction model (like/helpful).""" + +import enum +from datetime import datetime +from uuid import UUID + +import sqlalchemy as sa +from sqlalchemy.orm import Mapped, mapped_column + +from papyrus.core.database import Base + + +class ReactionType(enum.StrEnum): + LIKE = "like" + HELPFUL = "helpful" + + +class ReviewReaction(Base): + __tablename__ = "review_reactions" + + user_id: Mapped[UUID] = mapped_column( + sa.Uuid, sa.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True + ) + review_id: Mapped[UUID] = mapped_column( + sa.Uuid, sa.ForeignKey("reviews.id", ondelete="CASCADE"), primary_key=True + ) + reaction_type: Mapped[ReactionType] = mapped_column( + sa.Enum(ReactionType, name="reaction_type"), primary_key=True + ) + created_at: Mapped[datetime] = mapped_column(server_default=sa.func.now()) diff --git a/server/src/papyrus/models/user.py b/server/src/papyrus/models/user.py new file mode 100644 index 0000000..e2b86fa --- /dev/null +++ b/server/src/papyrus/models/user.py @@ -0,0 +1,38 @@ +"""User model for community profiles.""" + +import enum +from datetime import datetime +from uuid import UUID + +import sqlalchemy as sa +from sqlalchemy.orm import Mapped, mapped_column + +from papyrus.core.database import Base + + +class ProfileVisibility(enum.StrEnum): + PUBLIC = "public" + FRIENDS_ONLY = "friends_only" + PRIVATE = "private" + + +class User(Base): + __tablename__ = "users" + + id: Mapped[UUID] = mapped_column( + sa.Uuid, primary_key=True, server_default=sa.text("gen_random_uuid()") + ) + username: Mapped[str | None] = mapped_column(sa.String(30), unique=True, index=True) + display_name: Mapped[str] = mapped_column(sa.String(100)) + bio: Mapped[str | None] = mapped_column(sa.Text) + avatar_url: Mapped[str | None] = mapped_column(sa.String(500)) + email: Mapped[str | None] = mapped_column(sa.String(255)) + password_hash: Mapped[str | None] = mapped_column(sa.String(255)) + profile_visibility: Mapped[ProfileVisibility] = mapped_column( + sa.Enum(ProfileVisibility, name="profile_visibility"), + server_default=ProfileVisibility.PUBLIC.value, + ) + created_at: Mapped[datetime] = mapped_column(server_default=sa.func.now()) + updated_at: Mapped[datetime] = mapped_column( + server_default=sa.func.now(), onupdate=sa.func.now() + ) diff --git a/server/src/papyrus/models/user_book.py b/server/src/papyrus/models/user_book.py new file mode 100644 index 0000000..00db88d --- /dev/null +++ b/server/src/papyrus/models/user_book.py @@ -0,0 +1,51 @@ +"""User-to-book relationship model (reading status, visibility).""" + +import enum +from datetime import datetime +from uuid import UUID + +import sqlalchemy as sa +from sqlalchemy.orm import Mapped, mapped_column + +from papyrus.core.database import Base + + +class BookStatus(enum.StrEnum): + WANT_TO_READ = "want_to_read" + READING = "reading" + READ = "read" + DNF = "dnf" + PAUSED = "paused" + + +class BookVisibility(enum.StrEnum): + PUBLIC = "public" + FRIENDS = "friends" + PRIVATE = "private" + + +class UserBook(Base): + __tablename__ = "user_books" + + user_id: Mapped[UUID] = mapped_column( + sa.Uuid, sa.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True + ) + catalog_book_id: Mapped[UUID] = mapped_column( + sa.Uuid, sa.ForeignKey("catalog_books.id", ondelete="CASCADE"), primary_key=True + ) + status: Mapped[BookStatus] = mapped_column( + sa.Enum(BookStatus, name="book_status"), + server_default=BookStatus.WANT_TO_READ.value, + ) + visibility: Mapped[BookVisibility] = mapped_column( + sa.Enum(BookVisibility, name="book_visibility"), + server_default=BookVisibility.PUBLIC.value, + ) + started_at: Mapped[datetime | None] = mapped_column() + finished_at: Mapped[datetime | None] = mapped_column() + progress: Mapped[float | None] = mapped_column() + local_book_id: Mapped[str | None] = mapped_column(sa.String(36)) + created_at: Mapped[datetime] = mapped_column(server_default=sa.func.now()) + updated_at: Mapped[datetime] = mapped_column( + server_default=sa.func.now(), onupdate=sa.func.now() + ) From e5ef1d213fbecdba0426dc3805cfbb5a7acde7d5 Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 23 Feb 2026 02:29:56 +0200 Subject: [PATCH 04/17] feat(server): add Pydantic schemas for community features Co-Authored-By: Claude Opus 4.6 --- server/src/papyrus/schemas/catalog.py | 59 +++++++++++++++++++ .../src/papyrus/schemas/community_activity.py | 38 ++++++++++++ .../src/papyrus/schemas/community_profile.py | 32 ++++++++++ .../src/papyrus/schemas/community_rating.py | 37 ++++++++++++ .../src/papyrus/schemas/community_review.py | 49 +++++++++++++++ .../papyrus/schemas/community_user_book.py | 39 ++++++++++++ server/src/papyrus/schemas/social.py | 21 +++++++ 7 files changed, 275 insertions(+) create mode 100644 server/src/papyrus/schemas/catalog.py create mode 100644 server/src/papyrus/schemas/community_activity.py create mode 100644 server/src/papyrus/schemas/community_profile.py create mode 100644 server/src/papyrus/schemas/community_rating.py create mode 100644 server/src/papyrus/schemas/community_review.py create mode 100644 server/src/papyrus/schemas/community_user_book.py create mode 100644 server/src/papyrus/schemas/social.py diff --git a/server/src/papyrus/schemas/catalog.py b/server/src/papyrus/schemas/catalog.py new file mode 100644 index 0000000..4dbd687 --- /dev/null +++ b/server/src/papyrus/schemas/catalog.py @@ -0,0 +1,59 @@ +"""Community book catalog schemas.""" + +from datetime import date, datetime +from uuid import UUID + +from pydantic import BaseModel, Field + +from papyrus.schemas.common import BaseSchema, Pagination + + +class CatalogBookSummary(BaseSchema): + catalog_book_id: UUID + title: str + author: str + cover_url: str | None = None + average_rating: float | None = None + rating_count: int = 0 + review_count: int = 0 + + +class CatalogBookDetail(BaseSchema): + catalog_book_id: UUID + open_library_id: str | None = None + isbn: str | None = None + title: str + authors: list[str] | None = None + cover_url: str | None = None + description: str | None = None + page_count: int | None = None + published_date: date | None = None + genres: list[str] | None = None + average_rating: float | None = None + rating_count: int = 0 + review_count: int = 0 + added_by_user_id: UUID | None = None + verified: bool = False + created_at: datetime | None = None + + +class CatalogBookList(BaseModel): + books: list[CatalogBookSummary] + pagination: Pagination + + +class CreateCatalogBookRequest(BaseModel): + title: str = Field(..., max_length=500) + authors: list[str] = Field(..., min_length=1) + isbn: str | None = Field(None, max_length=13) + cover_url: str | None = Field(None, max_length=500) + description: str | None = None + page_count: int | None = Field(None, ge=1) + published_date: date | None = None + genres: list[str] | None = None + open_library_id: str | None = Field(None, max_length=50) + + +class CatalogSearchResult(BaseModel): + local_results: list[CatalogBookSummary] + open_library_results: list[CatalogBookSummary] diff --git a/server/src/papyrus/schemas/community_activity.py b/server/src/papyrus/schemas/community_activity.py new file mode 100644 index 0000000..6c2bc66 --- /dev/null +++ b/server/src/papyrus/schemas/community_activity.py @@ -0,0 +1,38 @@ +"""Activity feed schemas.""" + +from datetime import datetime +from typing import Any +from uuid import UUID + +from pydantic import BaseModel + +from papyrus.schemas.common import BaseSchema, Pagination + + +class ActivityUser(BaseModel): + user_id: UUID + display_name: str + username: str | None = None + avatar_url: str | None = None + + +class ActivityBook(BaseModel): + catalog_book_id: UUID + title: str + author: str + cover_url: str | None = None + + +class ActivityResponse(BaseSchema): + activity_id: UUID + user: ActivityUser + activity_type: str + description: str + book: ActivityBook | None = None + metadata: dict[str, Any] | None = None + created_at: datetime + + +class ActivityFeed(BaseModel): + activities: list[ActivityResponse] + pagination: Pagination diff --git a/server/src/papyrus/schemas/community_profile.py b/server/src/papyrus/schemas/community_profile.py new file mode 100644 index 0000000..2de3ca2 --- /dev/null +++ b/server/src/papyrus/schemas/community_profile.py @@ -0,0 +1,32 @@ +"""Community profile schemas.""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + +from papyrus.schemas.common import BaseSchema + + +class CommunityProfile(BaseSchema): + user_id: UUID + username: str | None = None + display_name: str + bio: str | None = None + avatar_url: str | None = None + profile_visibility: str = "public" + follower_count: int = 0 + following_count: int = 0 + book_count: int = 0 + review_count: int = 0 + is_following: bool = False + is_friend: bool = False + created_at: datetime | None = None + + +class UpdateProfileRequest(BaseModel): + username: str | None = Field(None, min_length=3, max_length=30, pattern=r"^[a-zA-Z0-9_]+$") + display_name: str | None = Field(None, min_length=1, max_length=100) + bio: str | None = Field(None, max_length=500) + avatar_url: str | None = Field(None, max_length=500) + profile_visibility: str | None = Field(None, pattern=r"^(public|friends_only|private)$") diff --git a/server/src/papyrus/schemas/community_rating.py b/server/src/papyrus/schemas/community_rating.py new file mode 100644 index 0000000..4f864ad --- /dev/null +++ b/server/src/papyrus/schemas/community_rating.py @@ -0,0 +1,37 @@ +"""Rating schemas.""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + +from papyrus.schemas.common import BaseSchema + + +class RatingResponse(BaseSchema): + user_id: UUID + catalog_book_id: UUID + score: int + created_at: datetime | None = None + updated_at: datetime | None = None + + +class CreateRatingRequest(BaseModel): + catalog_book_id: UUID + score: int = Field(..., ge=1, le=10) + + +class UpdateRatingRequest(BaseModel): + score: int = Field(..., ge=1, le=10) + + +class RatingDistribution(BaseModel): + score: int + count: int + + +class BookRatingsSummary(BaseModel): + catalog_book_id: UUID + average_rating: float | None = None + rating_count: int = 0 + distribution: list[RatingDistribution] = [] diff --git a/server/src/papyrus/schemas/community_review.py b/server/src/papyrus/schemas/community_review.py new file mode 100644 index 0000000..13dd03b --- /dev/null +++ b/server/src/papyrus/schemas/community_review.py @@ -0,0 +1,49 @@ +"""Review schemas.""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + +from papyrus.schemas.common import BaseSchema, Pagination + + +class ReviewResponse(BaseSchema): + review_id: UUID + user_id: UUID + catalog_book_id: UUID + author_display_name: str | None = None + author_username: str | None = None + author_avatar_url: str | None = None + title: str | None = None + body: str + contains_spoilers: bool = False + visibility: str = "public" + like_count: int = 0 + helpful_count: int = 0 + created_at: datetime | None = None + updated_at: datetime | None = None + + +class ReviewList(BaseModel): + reviews: list[ReviewResponse] + pagination: Pagination + + +class CreateReviewRequest(BaseModel): + catalog_book_id: UUID + title: str | None = Field(None, max_length=255) + body: str = Field(..., min_length=10, max_length=10000) + contains_spoilers: bool = False + visibility: str = Field("public", pattern=r"^(public|friends|private)$") + + +class UpdateReviewRequest(BaseModel): + title: str | None = Field(None, max_length=255) + body: str | None = Field(None, min_length=10, max_length=10000) + contains_spoilers: bool | None = None + visibility: str | None = Field(None, pattern=r"^(public|friends|private)$") + + +class ReviewReactionRequest(BaseModel): + reaction_type: str = Field(..., pattern=r"^(like|helpful)$") diff --git a/server/src/papyrus/schemas/community_user_book.py b/server/src/papyrus/schemas/community_user_book.py new file mode 100644 index 0000000..6634540 --- /dev/null +++ b/server/src/papyrus/schemas/community_user_book.py @@ -0,0 +1,39 @@ +"""User book tracking schemas.""" + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + +from papyrus.schemas.common import BaseSchema, Pagination + + +class UserBookResponse(BaseSchema): + user_id: UUID + catalog_book_id: UUID + book_title: str | None = None + book_author: str | None = None + book_cover_url: str | None = None + status: str = "want_to_read" + visibility: str = "public" + started_at: datetime | None = None + finished_at: datetime | None = None + progress: float | None = None + created_at: datetime | None = None + + +class UserBookList(BaseModel): + books: list[UserBookResponse] + pagination: Pagination + + +class CreateUserBookRequest(BaseModel): + catalog_book_id: UUID + status: str = Field("want_to_read", pattern=r"^(want_to_read|reading|read|dnf|paused)$") + visibility: str = Field("public", pattern=r"^(public|friends|private)$") + + +class UpdateUserBookRequest(BaseModel): + status: str | None = Field(None, pattern=r"^(want_to_read|reading|read|dnf|paused)$") + visibility: str | None = Field(None, pattern=r"^(public|friends|private)$") + progress: float | None = Field(None, ge=0, le=1) diff --git a/server/src/papyrus/schemas/social.py b/server/src/papyrus/schemas/social.py new file mode 100644 index 0000000..0446fda --- /dev/null +++ b/server/src/papyrus/schemas/social.py @@ -0,0 +1,21 @@ +"""Social feature schemas (follow, block).""" + +from uuid import UUID + +from pydantic import BaseModel + +from papyrus.schemas.common import BaseSchema, Pagination + + +class FollowUser(BaseSchema): + user_id: UUID + username: str | None = None + display_name: str + avatar_url: str | None = None + is_following: bool = False + is_friend: bool = False + + +class FollowList(BaseModel): + users: list[FollowUser] + pagination: Pagination From f12fcd15c5e5062f99b5350aff506b7c9859f7c3 Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 23 Feb 2026 02:33:19 +0200 Subject: [PATCH 05/17] feat(server): add community profile endpoints Co-Authored-By: Claude Opus 4.6 --- server/src/papyrus/api/routes/__init__.py | 2 + server/src/papyrus/api/routes/profiles.py | 77 +++++++++++++++++++++++ server/tests/test_profiles.py | 36 +++++++++++ 3 files changed, 115 insertions(+) create mode 100644 server/src/papyrus/api/routes/profiles.py create mode 100644 server/tests/test_profiles.py diff --git a/server/src/papyrus/api/routes/__init__.py b/server/src/papyrus/api/routes/__init__.py index 59c48cb..e49f850 100644 --- a/server/src/papyrus/api/routes/__init__.py +++ b/server/src/papyrus/api/routes/__init__.py @@ -10,6 +10,7 @@ files, goals, notes, + profiles, progress, reading_profiles, saved_filters, @@ -41,3 +42,4 @@ reading_profiles.router, prefix="/reading-profiles", tags=["Reading Profiles"] ) api_router.include_router(saved_filters.router, prefix="/saved-filters", tags=["Saved Filters"]) +api_router.include_router(profiles.router, prefix="/profiles", tags=["Profiles"]) diff --git a/server/src/papyrus/api/routes/profiles.py b/server/src/papyrus/api/routes/profiles.py new file mode 100644 index 0000000..da33d62 --- /dev/null +++ b/server/src/papyrus/api/routes/profiles.py @@ -0,0 +1,77 @@ +"""Community profile routes.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +from fastapi import APIRouter + +from papyrus.api.deps import CurrentUserId +from papyrus.schemas.community_profile import CommunityProfile, UpdateProfileRequest + +router = APIRouter() + + +@router.get("/me", response_model=CommunityProfile, summary="Get own community profile") +async def get_own_profile(user_id: CurrentUserId) -> CommunityProfile: + """Return the authenticated user's community profile.""" + return CommunityProfile( + user_id=user_id, + username=None, + display_name="Example User", + bio=None, + avatar_url=None, + profile_visibility="public", + follower_count=0, + following_count=0, + book_count=0, + review_count=0, + is_following=False, + is_friend=False, + created_at=datetime.now(UTC), + ) + + +@router.patch("/me", response_model=CommunityProfile, summary="Update community profile") +async def update_profile( + user_id: CurrentUserId, request: UpdateProfileRequest +) -> CommunityProfile: + """Update the authenticated user's community profile.""" + return CommunityProfile( + user_id=user_id, + username=request.username, + display_name=request.display_name or "Example User", + bio=request.bio, + avatar_url=request.avatar_url, + profile_visibility=request.profile_visibility or "public", + follower_count=0, + following_count=0, + book_count=0, + review_count=0, + is_following=False, + is_friend=False, + created_at=datetime.now(UTC), + ) + + +@router.get( + "/{username}", response_model=CommunityProfile, summary="Get user profile by username" +) +async def get_profile_by_username( + user_id: CurrentUserId, username: str +) -> CommunityProfile: + """Return a user's public community profile.""" + return CommunityProfile( + user_id=uuid4(), + username=username, + display_name=username.title(), + bio=None, + avatar_url=None, + profile_visibility="public", + follower_count=5, + following_count=3, + book_count=12, + review_count=4, + is_following=False, + is_friend=False, + created_at=datetime.now(UTC), + ) diff --git a/server/tests/test_profiles.py b/server/tests/test_profiles.py new file mode 100644 index 0000000..dc41e4a --- /dev/null +++ b/server/tests/test_profiles.py @@ -0,0 +1,36 @@ +"""Tests for community profile endpoints.""" + +from fastapi.testclient import TestClient + + +def test_get_own_profile(client: TestClient, auth_headers: dict[str, str]): + response = client.get("/v1/profiles/me", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert "user_id" in data + assert "display_name" in data + assert "follower_count" in data + + +def test_update_profile(client: TestClient, auth_headers: dict[str, str]): + response = client.patch( + "/v1/profiles/me", + headers=auth_headers, + json={"username": "testuser", "bio": "I love reading"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["username"] == "testuser" + assert data["bio"] == "I love reading" + + +def test_get_profile_by_username(client: TestClient, auth_headers: dict[str, str]): + response = client.get("/v1/profiles/someuser", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert "user_id" in data + + +def test_unauthorized_profile(client: TestClient): + response = client.get("/v1/profiles/me") + assert response.status_code in (401, 403) From 14f4ff9a4f4463eb005e567e953193665897565d Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 23 Feb 2026 02:34:53 +0200 Subject: [PATCH 06/17] feat(server): add social endpoints (follow, block) Co-Authored-By: Claude Opus 4.6 --- server/src/papyrus/api/routes/__init__.py | 2 + server/src/papyrus/api/routes/social.py | 83 +++++++++++++++++++++++ server/tests/test_social.py | 51 ++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 server/src/papyrus/api/routes/social.py create mode 100644 server/tests/test_social.py diff --git a/server/src/papyrus/api/routes/__init__.py b/server/src/papyrus/api/routes/__init__.py index e49f850..f753154 100644 --- a/server/src/papyrus/api/routes/__init__.py +++ b/server/src/papyrus/api/routes/__init__.py @@ -16,6 +16,7 @@ saved_filters, series, shelves, + social, storage, sync, tags, @@ -43,3 +44,4 @@ ) api_router.include_router(saved_filters.router, prefix="/saved-filters", tags=["Saved Filters"]) api_router.include_router(profiles.router, prefix="/profiles", tags=["Profiles"]) +api_router.include_router(social.router, prefix="/social", tags=["Social"]) diff --git a/server/src/papyrus/api/routes/social.py b/server/src/papyrus/api/routes/social.py new file mode 100644 index 0000000..1745391 --- /dev/null +++ b/server/src/papyrus/api/routes/social.py @@ -0,0 +1,83 @@ +"""Social routes (follow, block).""" + +from uuid import UUID + +from fastapi import APIRouter, Response, status + +from papyrus.api.deps import CurrentUserId, Pagination +from papyrus.schemas.common import Pagination as PaginationSchema +from papyrus.schemas.social import FollowList + +router = APIRouter() + + +@router.post("/follow/{target_user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def follow_user(user_id: CurrentUserId, target_user_id: UUID) -> Response: + """Follow a user.""" + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.delete("/follow/{target_user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def unfollow_user(user_id: CurrentUserId, target_user_id: UUID) -> Response: + """Unfollow a user.""" + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/followers", response_model=FollowList, summary="List followers") +async def list_followers(user_id: CurrentUserId, pagination: Pagination) -> FollowList: + """List users who follow the authenticated user.""" + return FollowList( + users=[], + pagination=PaginationSchema( + page=pagination.page, + limit=pagination.limit, + total=0, + total_pages=0, + has_next=False, + has_prev=False, + ), + ) + + +@router.get("/following", response_model=FollowList, summary="List following") +async def list_following(user_id: CurrentUserId, pagination: Pagination) -> FollowList: + """List users the authenticated user follows.""" + return FollowList( + users=[], + pagination=PaginationSchema( + page=pagination.page, + limit=pagination.limit, + total=0, + total_pages=0, + has_next=False, + has_prev=False, + ), + ) + + +@router.get("/friends", response_model=FollowList, summary="List friends") +async def list_friends(user_id: CurrentUserId, pagination: Pagination) -> FollowList: + """List mutual follows (friends).""" + return FollowList( + users=[], + pagination=PaginationSchema( + page=pagination.page, + limit=pagination.limit, + total=0, + total_pages=0, + has_next=False, + has_prev=False, + ), + ) + + +@router.post("/block/{target_user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def block_user(user_id: CurrentUserId, target_user_id: UUID) -> Response: + """Block a user.""" + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.delete("/block/{target_user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def unblock_user(user_id: CurrentUserId, target_user_id: UUID) -> Response: + """Unblock a user.""" + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/server/tests/test_social.py b/server/tests/test_social.py new file mode 100644 index 0000000..2cc8abb --- /dev/null +++ b/server/tests/test_social.py @@ -0,0 +1,51 @@ +"""Tests for social endpoints.""" + +from uuid import uuid4 + +from fastapi.testclient import TestClient + + +def test_follow_user(client: TestClient, auth_headers: dict[str, str]): + target = str(uuid4()) + response = client.post(f"/v1/social/follow/{target}", headers=auth_headers) + assert response.status_code == 204 + + +def test_unfollow_user(client: TestClient, auth_headers: dict[str, str]): + target = str(uuid4()) + response = client.delete(f"/v1/social/follow/{target}", headers=auth_headers) + assert response.status_code == 204 + + +def test_list_followers(client: TestClient, auth_headers: dict[str, str]): + response = client.get("/v1/social/followers", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert "users" in data + assert "pagination" in data + + +def test_list_following(client: TestClient, auth_headers: dict[str, str]): + response = client.get("/v1/social/following", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert "users" in data + + +def test_list_friends(client: TestClient, auth_headers: dict[str, str]): + response = client.get("/v1/social/friends", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert "users" in data + + +def test_block_user(client: TestClient, auth_headers: dict[str, str]): + target = str(uuid4()) + response = client.post(f"/v1/social/block/{target}", headers=auth_headers) + assert response.status_code == 204 + + +def test_unblock_user(client: TestClient, auth_headers: dict[str, str]): + target = str(uuid4()) + response = client.delete(f"/v1/social/block/{target}", headers=auth_headers) + assert response.status_code == 204 From 194b129ad489d5c4d7f864f45be3063fe293aa30 Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 23 Feb 2026 02:36:39 +0200 Subject: [PATCH 07/17] feat(server): add catalog endpoints (search, book details, reviews) Co-Authored-By: Claude Opus 4.6 --- server/src/papyrus/api/routes/__init__.py | 2 + server/src/papyrus/api/routes/catalog.py | 106 ++++++++++++++++++++++ server/tests/test_catalog.py | 52 +++++++++++ 3 files changed, 160 insertions(+) create mode 100644 server/src/papyrus/api/routes/catalog.py create mode 100644 server/tests/test_catalog.py diff --git a/server/src/papyrus/api/routes/__init__.py b/server/src/papyrus/api/routes/__init__.py index f753154..cba83f9 100644 --- a/server/src/papyrus/api/routes/__init__.py +++ b/server/src/papyrus/api/routes/__init__.py @@ -7,6 +7,7 @@ auth, bookmarks, books, + catalog, files, goals, notes, @@ -45,3 +46,4 @@ api_router.include_router(saved_filters.router, prefix="/saved-filters", tags=["Saved Filters"]) api_router.include_router(profiles.router, prefix="/profiles", tags=["Profiles"]) api_router.include_router(social.router, prefix="/social", tags=["Social"]) +api_router.include_router(catalog.router, prefix="/catalog", tags=["Catalog"]) diff --git a/server/src/papyrus/api/routes/catalog.py b/server/src/papyrus/api/routes/catalog.py new file mode 100644 index 0000000..62bf90e --- /dev/null +++ b/server/src/papyrus/api/routes/catalog.py @@ -0,0 +1,106 @@ +"""Community book catalog routes.""" + +from datetime import UTC, datetime +from typing import Annotated +from uuid import UUID, uuid4 + +from fastapi import APIRouter, Query, status + +from papyrus.api.deps import CurrentUserId, Pagination +from papyrus.schemas.catalog import ( + CatalogBookDetail, + CatalogSearchResult, + CreateCatalogBookRequest, +) +from papyrus.schemas.common import Pagination as PaginationSchema +from papyrus.schemas.community_rating import BookRatingsSummary +from papyrus.schemas.community_review import ReviewList + +router = APIRouter() + + +@router.get("/search", response_model=CatalogSearchResult, summary="Search books") +async def search_catalog( + user_id: CurrentUserId, + q: Annotated[str, Query(min_length=1, max_length=200)], +) -> CatalogSearchResult: + """Search for books in the local catalog and Open Library.""" + return CatalogSearchResult(local_results=[], open_library_results=[]) + + +@router.get("/books/{book_id}", response_model=CatalogBookDetail, summary="Get catalog book") +async def get_catalog_book(user_id: CurrentUserId, book_id: UUID) -> CatalogBookDetail: + """Get detailed information about a catalog book.""" + return CatalogBookDetail( + catalog_book_id=book_id, + title="Example Book", + authors=["Example Author"], + average_rating=None, + rating_count=0, + review_count=0, + created_at=datetime.now(UTC), + ) + + +@router.post( + "/books", + response_model=CatalogBookDetail, + status_code=status.HTTP_201_CREATED, + summary="Add book to catalog", +) +async def add_catalog_book( + user_id: CurrentUserId, request: CreateCatalogBookRequest +) -> CatalogBookDetail: + """Add a user-contributed book to the community catalog.""" + return CatalogBookDetail( + catalog_book_id=uuid4(), + title=request.title, + authors=request.authors, + isbn=request.isbn, + cover_url=request.cover_url, + description=request.description, + page_count=request.page_count, + published_date=request.published_date, + genres=request.genres, + open_library_id=request.open_library_id, + added_by_user_id=user_id, + verified=False, + average_rating=None, + rating_count=0, + review_count=0, + created_at=datetime.now(UTC), + ) + + +@router.get( + "/books/{book_id}/reviews", response_model=ReviewList, summary="Get book reviews" +) +async def get_book_reviews( + user_id: CurrentUserId, book_id: UUID, pagination: Pagination +) -> ReviewList: + """Get all reviews for a catalog book.""" + return ReviewList( + reviews=[], + pagination=PaginationSchema( + page=pagination.page, + limit=pagination.limit, + total=0, + total_pages=0, + has_next=False, + has_prev=False, + ), + ) + + +@router.get( + "/books/{book_id}/ratings/distribution", + response_model=BookRatingsSummary, + summary="Get rating distribution", +) +async def get_ratings_distribution( + user_id: CurrentUserId, book_id: UUID +) -> BookRatingsSummary: + """Get rating distribution for a catalog book.""" + return BookRatingsSummary( + catalog_book_id=book_id, average_rating=None, rating_count=0, distribution=[] + ) diff --git a/server/tests/test_catalog.py b/server/tests/test_catalog.py new file mode 100644 index 0000000..eb46bcc --- /dev/null +++ b/server/tests/test_catalog.py @@ -0,0 +1,52 @@ +"""Tests for catalog endpoints.""" + +from uuid import uuid4 + +from fastapi.testclient import TestClient + + +def test_search_catalog(client: TestClient, auth_headers: dict[str, str]): + response = client.get("/v1/catalog/search?q=dune", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert "local_results" in data + assert "open_library_results" in data + + +def test_get_catalog_book(client: TestClient, auth_headers: dict[str, str]): + book_id = str(uuid4()) + response = client.get(f"/v1/catalog/books/{book_id}", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert "catalog_book_id" in data + assert "title" in data + + +def test_add_catalog_book(client: TestClient, auth_headers: dict[str, str]): + response = client.post( + "/v1/catalog/books", + headers=auth_headers, + json={"title": "My Book", "authors": ["Author Name"]}, + ) + assert response.status_code == 201 + data = response.json() + assert data["title"] == "My Book" + + +def test_get_book_reviews(client: TestClient, auth_headers: dict[str, str]): + book_id = str(uuid4()) + response = client.get(f"/v1/catalog/books/{book_id}/reviews", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert "reviews" in data + + +def test_get_ratings_distribution(client: TestClient, auth_headers: dict[str, str]): + book_id = str(uuid4()) + response = client.get( + f"/v1/catalog/books/{book_id}/ratings/distribution", headers=auth_headers + ) + assert response.status_code == 200 + data = response.json() + assert "catalog_book_id" in data + assert "distribution" in data From 55b422d045968aa6080105378ed63c18f9c9f20a Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 23 Feb 2026 02:38:24 +0200 Subject: [PATCH 08/17] feat(server): add rating endpoints (1-10 scale) Co-Authored-By: Claude Opus 4.6 --- server/src/papyrus/api/routes/__init__.py | 2 + server/src/papyrus/api/routes/ratings.py | 47 +++++++++++++++++++++++ server/tests/test_ratings.py | 43 +++++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 server/src/papyrus/api/routes/ratings.py create mode 100644 server/tests/test_ratings.py diff --git a/server/src/papyrus/api/routes/__init__.py b/server/src/papyrus/api/routes/__init__.py index cba83f9..adba630 100644 --- a/server/src/papyrus/api/routes/__init__.py +++ b/server/src/papyrus/api/routes/__init__.py @@ -13,6 +13,7 @@ notes, profiles, progress, + ratings, reading_profiles, saved_filters, series, @@ -47,3 +48,4 @@ api_router.include_router(profiles.router, prefix="/profiles", tags=["Profiles"]) api_router.include_router(social.router, prefix="/social", tags=["Social"]) api_router.include_router(catalog.router, prefix="/catalog", tags=["Catalog"]) +api_router.include_router(ratings.router, prefix="/ratings", tags=["Ratings"]) diff --git a/server/src/papyrus/api/routes/ratings.py b/server/src/papyrus/api/routes/ratings.py new file mode 100644 index 0000000..23fd4d8 --- /dev/null +++ b/server/src/papyrus/api/routes/ratings.py @@ -0,0 +1,47 @@ +"""Rating routes.""" + +from datetime import UTC, datetime +from uuid import UUID + +from fastapi import APIRouter, Response, status + +from papyrus.api.deps import CurrentUserId +from papyrus.schemas.community_rating import ( + CreateRatingRequest, + RatingResponse, + UpdateRatingRequest, +) + +router = APIRouter() + + +@router.post("", response_model=RatingResponse, status_code=status.HTTP_201_CREATED) +async def rate_book(user_id: CurrentUserId, request: CreateRatingRequest) -> RatingResponse: + """Rate a book (1-10 scale).""" + return RatingResponse( + user_id=user_id, + catalog_book_id=request.catalog_book_id, + score=request.score, + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + + +@router.patch("/{book_id}", response_model=RatingResponse) +async def update_rating( + user_id: CurrentUserId, book_id: UUID, request: UpdateRatingRequest +) -> RatingResponse: + """Update an existing rating.""" + return RatingResponse( + user_id=user_id, + catalog_book_id=book_id, + score=request.score, + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + + +@router.delete("/{book_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_rating(user_id: CurrentUserId, book_id: UUID) -> Response: + """Remove a rating.""" + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/server/tests/test_ratings.py b/server/tests/test_ratings.py new file mode 100644 index 0000000..1a56aea --- /dev/null +++ b/server/tests/test_ratings.py @@ -0,0 +1,43 @@ +"""Tests for rating endpoints.""" + +from uuid import uuid4 + +from fastapi.testclient import TestClient + + +def test_rate_book(client: TestClient, auth_headers: dict[str, str]): + response = client.post( + "/v1/ratings", + headers=auth_headers, + json={"catalog_book_id": str(uuid4()), "score": 8}, + ) + assert response.status_code == 201 + data = response.json() + assert data["score"] == 8 + + +def test_update_rating(client: TestClient, auth_headers: dict[str, str]): + book_id = str(uuid4()) + response = client.patch( + f"/v1/ratings/{book_id}", + headers=auth_headers, + json={"score": 9}, + ) + assert response.status_code == 200 + data = response.json() + assert data["score"] == 9 + + +def test_delete_rating(client: TestClient, auth_headers: dict[str, str]): + book_id = str(uuid4()) + response = client.delete(f"/v1/ratings/{book_id}", headers=auth_headers) + assert response.status_code == 204 + + +def test_invalid_rating_score(client: TestClient, auth_headers: dict[str, str]): + response = client.post( + "/v1/ratings", + headers=auth_headers, + json={"catalog_book_id": str(uuid4()), "score": 11}, + ) + assert response.status_code == 422 From 5cdd0889ab4f4b7ef39d529edb8a4eaf42d01565 Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 23 Feb 2026 02:38:53 +0200 Subject: [PATCH 09/17] feat(server): add review endpoints (CRUD, reactions) Co-Authored-By: Claude Opus 4.6 --- server/src/papyrus/api/routes/__init__.py | 2 + server/src/papyrus/api/routes/reviews.py | 85 +++++++++++++++++++++++ server/tests/test_reviews.py | 71 +++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 server/src/papyrus/api/routes/reviews.py create mode 100644 server/tests/test_reviews.py diff --git a/server/src/papyrus/api/routes/__init__.py b/server/src/papyrus/api/routes/__init__.py index adba630..7d989ff 100644 --- a/server/src/papyrus/api/routes/__init__.py +++ b/server/src/papyrus/api/routes/__init__.py @@ -15,6 +15,7 @@ progress, ratings, reading_profiles, + reviews, saved_filters, series, shelves, @@ -49,3 +50,4 @@ api_router.include_router(social.router, prefix="/social", tags=["Social"]) api_router.include_router(catalog.router, prefix="/catalog", tags=["Catalog"]) api_router.include_router(ratings.router, prefix="/ratings", tags=["Ratings"]) +api_router.include_router(reviews.router, prefix="/reviews", tags=["Reviews"]) diff --git a/server/src/papyrus/api/routes/reviews.py b/server/src/papyrus/api/routes/reviews.py new file mode 100644 index 0000000..1a018f4 --- /dev/null +++ b/server/src/papyrus/api/routes/reviews.py @@ -0,0 +1,85 @@ +"""Review routes.""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +from fastapi import APIRouter, Response, status + +from papyrus.api.deps import CurrentUserId +from papyrus.schemas.community_review import ( + CreateReviewRequest, + ReviewReactionRequest, + ReviewResponse, + UpdateReviewRequest, +) + +router = APIRouter() + + +@router.post("", response_model=ReviewResponse, status_code=status.HTTP_201_CREATED) +async def create_review( + user_id: CurrentUserId, request: CreateReviewRequest +) -> ReviewResponse: + """Create a review for a book.""" + return ReviewResponse( + review_id=uuid4(), + user_id=user_id, + catalog_book_id=request.catalog_book_id, + title=request.title, + body=request.body, + contains_spoilers=request.contains_spoilers, + visibility=request.visibility, + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + + +@router.get("/{review_id}", response_model=ReviewResponse) +async def get_review(user_id: CurrentUserId, review_id: UUID) -> ReviewResponse: + """Get a review by ID.""" + return ReviewResponse( + review_id=review_id, + user_id=user_id, + catalog_book_id=uuid4(), + body="Example review text for this book.", + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + + +@router.patch("/{review_id}", response_model=ReviewResponse) +async def update_review( + user_id: CurrentUserId, review_id: UUID, request: UpdateReviewRequest +) -> ReviewResponse: + """Update a review.""" + return ReviewResponse( + review_id=review_id, + user_id=user_id, + catalog_book_id=uuid4(), + title=request.title, + body=request.body or "Original review text.", + contains_spoilers=request.contains_spoilers or False, + visibility=request.visibility or "public", + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + + +@router.delete("/{review_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_review(user_id: CurrentUserId, review_id: UUID) -> Response: + """Delete a review.""" + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.post("/{review_id}/react", status_code=status.HTTP_204_NO_CONTENT) +async def react_to_review( + user_id: CurrentUserId, review_id: UUID, request: ReviewReactionRequest +) -> Response: + """Add a reaction (like/helpful) to a review.""" + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.delete("/{review_id}/react", status_code=status.HTTP_204_NO_CONTENT) +async def remove_reaction(user_id: CurrentUserId, review_id: UUID) -> Response: + """Remove a reaction from a review.""" + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/server/tests/test_reviews.py b/server/tests/test_reviews.py new file mode 100644 index 0000000..aa5795c --- /dev/null +++ b/server/tests/test_reviews.py @@ -0,0 +1,71 @@ +"""Tests for review endpoints.""" + +from uuid import uuid4 + +from fastapi.testclient import TestClient + + +def test_create_review(client: TestClient, auth_headers: dict[str, str]): + response = client.post( + "/v1/reviews", + headers=auth_headers, + json={ + "catalog_book_id": str(uuid4()), + "body": "This is a great book that I really enjoyed reading.", + "contains_spoilers": False, + }, + ) + assert response.status_code == 201 + data = response.json() + assert "review_id" in data + assert data["contains_spoilers"] is False + + +def test_get_review(client: TestClient, auth_headers: dict[str, str]): + review_id = str(uuid4()) + response = client.get(f"/v1/reviews/{review_id}", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert "review_id" in data + assert "body" in data + + +def test_update_review(client: TestClient, auth_headers: dict[str, str]): + review_id = str(uuid4()) + response = client.patch( + f"/v1/reviews/{review_id}", + headers=auth_headers, + json={"body": "Updated review text that is long enough."}, + ) + assert response.status_code == 200 + + +def test_delete_review(client: TestClient, auth_headers: dict[str, str]): + review_id = str(uuid4()) + response = client.delete(f"/v1/reviews/{review_id}", headers=auth_headers) + assert response.status_code == 204 + + +def test_react_to_review(client: TestClient, auth_headers: dict[str, str]): + review_id = str(uuid4()) + response = client.post( + f"/v1/reviews/{review_id}/react", + headers=auth_headers, + json={"reaction_type": "like"}, + ) + assert response.status_code == 204 + + +def test_remove_reaction(client: TestClient, auth_headers: dict[str, str]): + review_id = str(uuid4()) + response = client.delete(f"/v1/reviews/{review_id}/react", headers=auth_headers) + assert response.status_code == 204 + + +def test_review_body_too_short(client: TestClient, auth_headers: dict[str, str]): + response = client.post( + "/v1/reviews", + headers=auth_headers, + json={"catalog_book_id": str(uuid4()), "body": "Short"}, + ) + assert response.status_code == 422 From 6bb702ca1c9911db042ba816914dc1f9644af0ae Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 23 Feb 2026 02:40:54 +0200 Subject: [PATCH 10/17] feat(server): add user book tracking and activity feed endpoints Co-Authored-By: Claude Opus 4.6 --- server/src/papyrus/api/routes/__init__.py | 4 ++ server/src/papyrus/api/routes/feed.py | 45 +++++++++++++++++++ server/src/papyrus/api/routes/user_books.py | 48 +++++++++++++++++++++ server/tests/test_feed.py | 18 ++++++++ server/tests/test_user_books.py | 34 +++++++++++++++ 5 files changed, 149 insertions(+) create mode 100644 server/src/papyrus/api/routes/feed.py create mode 100644 server/src/papyrus/api/routes/user_books.py create mode 100644 server/tests/test_feed.py create mode 100644 server/tests/test_user_books.py diff --git a/server/src/papyrus/api/routes/__init__.py b/server/src/papyrus/api/routes/__init__.py index 7d989ff..8857105 100644 --- a/server/src/papyrus/api/routes/__init__.py +++ b/server/src/papyrus/api/routes/__init__.py @@ -8,6 +8,7 @@ bookmarks, books, catalog, + feed, files, goals, notes, @@ -23,6 +24,7 @@ storage, sync, tags, + user_books, users, ) @@ -51,3 +53,5 @@ api_router.include_router(catalog.router, prefix="/catalog", tags=["Catalog"]) api_router.include_router(ratings.router, prefix="/ratings", tags=["Ratings"]) api_router.include_router(reviews.router, prefix="/reviews", tags=["Reviews"]) +api_router.include_router(user_books.router, prefix="/user-books", tags=["User Books"]) +api_router.include_router(feed.router, prefix="/feed", tags=["Feed"]) diff --git a/server/src/papyrus/api/routes/feed.py b/server/src/papyrus/api/routes/feed.py new file mode 100644 index 0000000..2986de8 --- /dev/null +++ b/server/src/papyrus/api/routes/feed.py @@ -0,0 +1,45 @@ +"""Activity feed routes.""" + +from fastapi import APIRouter + +from papyrus.api.deps import CurrentUserId, Pagination +from papyrus.schemas.common import Pagination as PaginationSchema +from papyrus.schemas.community_activity import ActivityFeed + +router = APIRouter() + + +@router.get("", response_model=ActivityFeed, summary="Personal feed") +async def get_personal_feed( + user_id: CurrentUserId, pagination: Pagination +) -> ActivityFeed: + """Get activity feed from followed users.""" + return ActivityFeed( + activities=[], + pagination=PaginationSchema( + page=pagination.page, + limit=pagination.limit, + total=0, + total_pages=0, + has_next=False, + has_prev=False, + ), + ) + + +@router.get("/global", response_model=ActivityFeed, summary="Global feed") +async def get_global_feed( + user_id: CurrentUserId, pagination: Pagination +) -> ActivityFeed: + """Get global/trending activity feed.""" + return ActivityFeed( + activities=[], + pagination=PaginationSchema( + page=pagination.page, + limit=pagination.limit, + total=0, + total_pages=0, + has_next=False, + has_prev=False, + ), + ) diff --git a/server/src/papyrus/api/routes/user_books.py b/server/src/papyrus/api/routes/user_books.py new file mode 100644 index 0000000..ef66e1a --- /dev/null +++ b/server/src/papyrus/api/routes/user_books.py @@ -0,0 +1,48 @@ +"""User book tracking routes.""" + +from datetime import UTC, datetime +from uuid import UUID + +from fastapi import APIRouter, Response, status + +from papyrus.api.deps import CurrentUserId +from papyrus.schemas.community_user_book import ( + CreateUserBookRequest, + UpdateUserBookRequest, + UserBookResponse, +) + +router = APIRouter() + + +@router.post("", response_model=UserBookResponse, status_code=status.HTTP_201_CREATED) +async def add_book(user_id: CurrentUserId, request: CreateUserBookRequest) -> UserBookResponse: + """Add a book to the user's community library.""" + return UserBookResponse( + user_id=user_id, + catalog_book_id=request.catalog_book_id, + status=request.status, + visibility=request.visibility, + created_at=datetime.now(UTC), + ) + + +@router.patch("/{book_id}", response_model=UserBookResponse) +async def update_book( + user_id: CurrentUserId, book_id: UUID, request: UpdateUserBookRequest +) -> UserBookResponse: + """Update book status or visibility.""" + return UserBookResponse( + user_id=user_id, + catalog_book_id=book_id, + status=request.status or "want_to_read", + visibility=request.visibility or "public", + progress=request.progress, + created_at=datetime.now(UTC), + ) + + +@router.delete("/{book_id}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_book(user_id: CurrentUserId, book_id: UUID) -> Response: + """Remove a book from the user's community library.""" + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/server/tests/test_feed.py b/server/tests/test_feed.py new file mode 100644 index 0000000..5204812 --- /dev/null +++ b/server/tests/test_feed.py @@ -0,0 +1,18 @@ +"""Tests for activity feed endpoints.""" + +from fastapi.testclient import TestClient + + +def test_get_personal_feed(client: TestClient, auth_headers: dict[str, str]): + response = client.get("/v1/feed", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert "activities" in data + assert "pagination" in data + + +def test_get_global_feed(client: TestClient, auth_headers: dict[str, str]): + response = client.get("/v1/feed/global", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert "activities" in data diff --git a/server/tests/test_user_books.py b/server/tests/test_user_books.py new file mode 100644 index 0000000..661f184 --- /dev/null +++ b/server/tests/test_user_books.py @@ -0,0 +1,34 @@ +"""Tests for user book tracking endpoints.""" + +from uuid import uuid4 + +from fastapi.testclient import TestClient + + +def test_add_book_to_library(client: TestClient, auth_headers: dict[str, str]): + response = client.post( + "/v1/user-books", + headers=auth_headers, + json={"catalog_book_id": str(uuid4()), "status": "want_to_read"}, + ) + assert response.status_code == 201 + data = response.json() + assert data["status"] == "want_to_read" + + +def test_update_book_status(client: TestClient, auth_headers: dict[str, str]): + book_id = str(uuid4()) + response = client.patch( + f"/v1/user-books/{book_id}", + headers=auth_headers, + json={"status": "reading"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "reading" + + +def test_remove_book_from_library(client: TestClient, auth_headers: dict[str, str]): + book_id = str(uuid4()) + response = client.delete(f"/v1/user-books/{book_id}", headers=auth_headers) + assert response.status_code == 204 From d1c81ff46910be7c9e6a18e3d23b4c10e95b121c Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 23 Feb 2026 02:44:30 +0200 Subject: [PATCH 11/17] Format community routes for line length compliance --- server/src/papyrus/api/routes/catalog.py | 8 ++------ server/src/papyrus/api/routes/feed.py | 8 ++------ server/src/papyrus/api/routes/profiles.py | 12 +++--------- server/src/papyrus/api/routes/reviews.py | 4 +--- 4 files changed, 8 insertions(+), 24 deletions(-) diff --git a/server/src/papyrus/api/routes/catalog.py b/server/src/papyrus/api/routes/catalog.py index 62bf90e..1abea9c 100644 --- a/server/src/papyrus/api/routes/catalog.py +++ b/server/src/papyrus/api/routes/catalog.py @@ -72,9 +72,7 @@ async def add_catalog_book( ) -@router.get( - "/books/{book_id}/reviews", response_model=ReviewList, summary="Get book reviews" -) +@router.get("/books/{book_id}/reviews", response_model=ReviewList, summary="Get book reviews") async def get_book_reviews( user_id: CurrentUserId, book_id: UUID, pagination: Pagination ) -> ReviewList: @@ -97,9 +95,7 @@ async def get_book_reviews( response_model=BookRatingsSummary, summary="Get rating distribution", ) -async def get_ratings_distribution( - user_id: CurrentUserId, book_id: UUID -) -> BookRatingsSummary: +async def get_ratings_distribution(user_id: CurrentUserId, book_id: UUID) -> BookRatingsSummary: """Get rating distribution for a catalog book.""" return BookRatingsSummary( catalog_book_id=book_id, average_rating=None, rating_count=0, distribution=[] diff --git a/server/src/papyrus/api/routes/feed.py b/server/src/papyrus/api/routes/feed.py index 2986de8..f612360 100644 --- a/server/src/papyrus/api/routes/feed.py +++ b/server/src/papyrus/api/routes/feed.py @@ -10,9 +10,7 @@ @router.get("", response_model=ActivityFeed, summary="Personal feed") -async def get_personal_feed( - user_id: CurrentUserId, pagination: Pagination -) -> ActivityFeed: +async def get_personal_feed(user_id: CurrentUserId, pagination: Pagination) -> ActivityFeed: """Get activity feed from followed users.""" return ActivityFeed( activities=[], @@ -28,9 +26,7 @@ async def get_personal_feed( @router.get("/global", response_model=ActivityFeed, summary="Global feed") -async def get_global_feed( - user_id: CurrentUserId, pagination: Pagination -) -> ActivityFeed: +async def get_global_feed(user_id: CurrentUserId, pagination: Pagination) -> ActivityFeed: """Get global/trending activity feed.""" return ActivityFeed( activities=[], diff --git a/server/src/papyrus/api/routes/profiles.py b/server/src/papyrus/api/routes/profiles.py index da33d62..3beaa75 100644 --- a/server/src/papyrus/api/routes/profiles.py +++ b/server/src/papyrus/api/routes/profiles.py @@ -32,9 +32,7 @@ async def get_own_profile(user_id: CurrentUserId) -> CommunityProfile: @router.patch("/me", response_model=CommunityProfile, summary="Update community profile") -async def update_profile( - user_id: CurrentUserId, request: UpdateProfileRequest -) -> CommunityProfile: +async def update_profile(user_id: CurrentUserId, request: UpdateProfileRequest) -> CommunityProfile: """Update the authenticated user's community profile.""" return CommunityProfile( user_id=user_id, @@ -53,12 +51,8 @@ async def update_profile( ) -@router.get( - "/{username}", response_model=CommunityProfile, summary="Get user profile by username" -) -async def get_profile_by_username( - user_id: CurrentUserId, username: str -) -> CommunityProfile: +@router.get("/{username}", response_model=CommunityProfile, summary="Get user profile by username") +async def get_profile_by_username(user_id: CurrentUserId, username: str) -> CommunityProfile: """Return a user's public community profile.""" return CommunityProfile( user_id=uuid4(), diff --git a/server/src/papyrus/api/routes/reviews.py b/server/src/papyrus/api/routes/reviews.py index 1a018f4..efbc9b5 100644 --- a/server/src/papyrus/api/routes/reviews.py +++ b/server/src/papyrus/api/routes/reviews.py @@ -17,9 +17,7 @@ @router.post("", response_model=ReviewResponse, status_code=status.HTTP_201_CREATED) -async def create_review( - user_id: CurrentUserId, request: CreateReviewRequest -) -> ReviewResponse: +async def create_review(user_id: CurrentUserId, request: CreateReviewRequest) -> ReviewResponse: """Create a review for a book.""" return ReviewResponse( review_id=uuid4(), From 183bda791f78dc724d5583f0898e8706d57babb2 Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 23 Feb 2026 02:46:10 +0200 Subject: [PATCH 12/17] feat(client): add API client service with Firebase JWT auth Co-Authored-By: Claude Opus 4.6 --- client/lib/services/api_client.dart | 114 ++++++++++++++++++++++ client/test/services/api_client_test.dart | 26 +++++ 2 files changed, 140 insertions(+) create mode 100644 client/lib/services/api_client.dart create mode 100644 client/test/services/api_client_test.dart diff --git a/client/lib/services/api_client.dart b/client/lib/services/api_client.dart new file mode 100644 index 0000000..cc0173d --- /dev/null +++ b/client/lib/services/api_client.dart @@ -0,0 +1,114 @@ +import 'dart:convert'; + +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:http/http.dart' as http; + +/// HTTP client for community API calls. +/// Attaches Firebase Auth JWT to all requests. +/// Community features are online-only — no offline queue. +class ApiClient { + final String baseUrl; + final http.Client _httpClient; + + ApiClient({required this.baseUrl, http.Client? httpClient}) + : _httpClient = httpClient ?? http.Client(); + + /// Build a URI from path and optional query parameters. + Uri buildUri(String path, {Map? queryParams}) { + final base = Uri.parse(baseUrl); + return base.replace( + path: path, + queryParameters: queryParams?.isNotEmpty == true ? queryParams : null, + ); + } + + /// Get the current Firebase Auth token. + Future _getToken() async { + final user = FirebaseAuth.instance.currentUser; + if (user == null) return null; + return user.getIdToken(); + } + + /// Build headers with auth token. + Future> _headers() async { + final token = await _getToken(); + return { + 'Content-Type': 'application/json', + if (token != null) 'Authorization': 'Bearer $token', + }; + } + + /// Perform a GET request. + Future get( + String path, { + Map? queryParams, + }) async { + final uri = buildUri(path, queryParams: queryParams); + final headers = await _headers(); + final response = await _httpClient.get(uri, headers: headers); + return ApiResponse.fromHttpResponse(response); + } + + /// Perform a POST request. + Future post(String path, {Map? body}) async { + final uri = buildUri(path); + final headers = await _headers(); + final response = await _httpClient.post( + uri, + headers: headers, + body: body != null ? jsonEncode(body) : null, + ); + return ApiResponse.fromHttpResponse(response); + } + + /// Perform a PATCH request. + Future patch(String path, {Map? body}) async { + final uri = buildUri(path); + final headers = await _headers(); + final response = await _httpClient.patch( + uri, + headers: headers, + body: body != null ? jsonEncode(body) : null, + ); + return ApiResponse.fromHttpResponse(response); + } + + /// Perform a DELETE request. + Future delete(String path) async { + final uri = buildUri(path); + final headers = await _headers(); + final response = await _httpClient.delete(uri, headers: headers); + return ApiResponse.fromHttpResponse(response); + } + + void dispose() { + _httpClient.close(); + } +} + +/// Wrapper around HTTP response with convenience methods. +class ApiResponse { + final int statusCode; + final String body; + final Map headers; + + const ApiResponse({ + required this.statusCode, + required this.body, + required this.headers, + }); + + factory ApiResponse.fromHttpResponse(http.Response response) { + return ApiResponse( + statusCode: response.statusCode, + body: response.body, + headers: response.headers, + ); + } + + bool get isSuccess => statusCode >= 200 && statusCode < 300; + + Map get json => jsonDecode(body) as Map; + + List get jsonList => jsonDecode(body) as List; +} diff --git a/client/test/services/api_client_test.dart b/client/test/services/api_client_test.dart new file mode 100644 index 0000000..c0193ae --- /dev/null +++ b/client/test/services/api_client_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/services/api_client.dart'; + +void main() { + group('ApiClient', () { + test('creates instance with base URL', () { + final client = ApiClient(baseUrl: 'http://localhost:8080'); + expect(client.baseUrl, 'http://localhost:8080'); + }); + + test('builds URL with path', () { + final client = ApiClient(baseUrl: 'http://localhost:8080'); + final uri = client.buildUri('/v1/feed'); + expect(uri.toString(), 'http://localhost:8080/v1/feed'); + }); + + test('builds URL with query params', () { + final client = ApiClient(baseUrl: 'http://localhost:8080'); + final uri = client.buildUri( + '/v1/catalog/search', + queryParams: {'q': 'dune'}, + ); + expect(uri.toString(), 'http://localhost:8080/v1/catalog/search?q=dune'); + }); + }); +} From fbda728b0320ded818bbe46ba24a7da4d4114cd7 Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 23 Feb 2026 02:48:35 +0200 Subject: [PATCH 13/17] feat(client): add community data models Co-Authored-By: Claude Opus 4.6 --- client/lib/models/activity_item.dart | 73 ++++++++++++ client/lib/models/catalog_book.dart | 66 +++++++++++ client/lib/models/community_review.dart | 61 ++++++++++ client/lib/models/community_user.dart | 94 ++++++++++++++++ client/test/models/community_models_test.dart | 104 ++++++++++++++++++ 5 files changed, 398 insertions(+) create mode 100644 client/lib/models/activity_item.dart create mode 100644 client/lib/models/catalog_book.dart create mode 100644 client/lib/models/community_review.dart create mode 100644 client/lib/models/community_user.dart create mode 100644 client/test/models/community_models_test.dart diff --git a/client/lib/models/activity_item.dart b/client/lib/models/activity_item.dart new file mode 100644 index 0000000..c39eb14 --- /dev/null +++ b/client/lib/models/activity_item.dart @@ -0,0 +1,73 @@ +/// User summary within an activity feed item. +class ActivityUser { + final String userId; + final String displayName; + final String? username; + final String? avatarUrl; + + const ActivityUser({ + required this.userId, + required this.displayName, + this.username, + this.avatarUrl, + }); + + factory ActivityUser.fromJson(Map json) => ActivityUser( + userId: json['user_id'] as String, + displayName: json['display_name'] as String, + username: json['username'] as String?, + avatarUrl: json['avatar_url'] as String?, + ); +} + +/// Book summary within an activity feed item. +class ActivityBook { + final String catalogBookId; + final String title; + final String author; + final String? coverImageUrl; + + const ActivityBook({ + required this.catalogBookId, + required this.title, + required this.author, + this.coverImageUrl, + }); + + factory ActivityBook.fromJson(Map json) => ActivityBook( + catalogBookId: json['catalog_book_id'] as String, + title: json['title'] as String, + author: json['author'] as String, + coverImageUrl: json['cover_url'] as String?, + ); +} + +/// Single activity feed item. +class ActivityItem { + final String activityId; + final ActivityUser user; + final String activityType; + final String description; + final ActivityBook? book; + final DateTime createdAt; + + const ActivityItem({ + required this.activityId, + required this.user, + required this.activityType, + required this.description, + this.book, + required this.createdAt, + }); + + factory ActivityItem.fromJson(Map json) => ActivityItem( + activityId: json['activity_id'] as String, + user: ActivityUser.fromJson(json['user'] as Map), + activityType: json['activity_type'] as String, + description: json['description'] as String, + book: json['book'] != null + ? ActivityBook.fromJson(json['book'] as Map) + : null, + createdAt: DateTime.parse(json['created_at'] as String), + ); +} diff --git a/client/lib/models/catalog_book.dart b/client/lib/models/catalog_book.dart new file mode 100644 index 0000000..b24fc8f --- /dev/null +++ b/client/lib/models/catalog_book.dart @@ -0,0 +1,66 @@ +/// Community book catalog model. +class CatalogBook { + final String catalogBookId; + final String? openLibraryId; + final String? isbn; + final String title; + final String author; + final List? authors; + final String? coverImageUrl; + final String? description; + final int? pageCount; + final double? averageRating; + final int ratingCount; + final int reviewCount; + + const CatalogBook({ + required this.catalogBookId, + this.openLibraryId, + this.isbn, + required this.title, + required this.author, + this.authors, + this.coverImageUrl, + this.description, + this.pageCount, + this.averageRating, + this.ratingCount = 0, + this.reviewCount = 0, + }); + + factory CatalogBook.fromJson(Map json) { + final authors = json['authors'] as List?; + final authorStr = authors != null && authors.isNotEmpty + ? authors.first as String + : json['author'] as String? ?? 'Unknown'; + + return CatalogBook( + catalogBookId: json['catalog_book_id'] as String, + openLibraryId: json['open_library_id'] as String?, + isbn: json['isbn'] as String?, + title: json['title'] as String, + author: authorStr, + authors: authors?.cast(), + coverImageUrl: json['cover_url'] as String?, + description: json['description'] as String?, + pageCount: json['page_count'] as int?, + averageRating: (json['average_rating'] as num?)?.toDouble(), + ratingCount: json['rating_count'] as int? ?? 0, + reviewCount: json['review_count'] as int? ?? 0, + ); + } + + Map toJson() => { + 'catalog_book_id': catalogBookId, + 'open_library_id': openLibraryId, + 'isbn': isbn, + 'title': title, + 'authors': authors ?? [author], + 'cover_url': coverImageUrl, + 'description': description, + 'page_count': pageCount, + 'average_rating': averageRating, + 'rating_count': ratingCount, + 'review_count': reviewCount, + }; +} diff --git a/client/lib/models/community_review.dart b/client/lib/models/community_review.dart new file mode 100644 index 0000000..5fe4aad --- /dev/null +++ b/client/lib/models/community_review.dart @@ -0,0 +1,61 @@ +/// Community book review model. +class CommunityReview { + final String reviewId; + final String userId; + final String catalogBookId; + final String? authorDisplayName; + final String? authorUsername; + final String? authorAvatarUrl; + final String? title; + final String body; + final bool containsSpoilers; + final String visibility; + final int likeCount; + final int helpfulCount; + final DateTime? createdAt; + + const CommunityReview({ + required this.reviewId, + required this.userId, + required this.catalogBookId, + this.authorDisplayName, + this.authorUsername, + this.authorAvatarUrl, + this.title, + required this.body, + this.containsSpoilers = false, + this.visibility = 'public', + this.likeCount = 0, + this.helpfulCount = 0, + this.createdAt, + }); + + factory CommunityReview.fromJson(Map json) => + CommunityReview( + reviewId: json['review_id'] as String, + userId: json['user_id'] as String, + catalogBookId: json['catalog_book_id'] as String, + authorDisplayName: json['author_display_name'] as String?, + authorUsername: json['author_username'] as String?, + authorAvatarUrl: json['author_avatar_url'] as String?, + title: json['title'] as String?, + body: json['body'] as String, + containsSpoilers: json['contains_spoilers'] as bool? ?? false, + visibility: json['visibility'] as String? ?? 'public', + likeCount: json['like_count'] as int? ?? 0, + helpfulCount: json['helpful_count'] as int? ?? 0, + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at'] as String) + : null, + ); + + Map toJson() => { + 'review_id': reviewId, + 'user_id': userId, + 'catalog_book_id': catalogBookId, + 'title': title, + 'body': body, + 'contains_spoilers': containsSpoilers, + 'visibility': visibility, + }; +} diff --git a/client/lib/models/community_user.dart b/client/lib/models/community_user.dart new file mode 100644 index 0000000..d535f8e --- /dev/null +++ b/client/lib/models/community_user.dart @@ -0,0 +1,94 @@ +/// Community user profile model. +class CommunityUser { + final String userId; + final String? username; + final String displayName; + final String? bio; + final String? avatarUrl; + final String profileVisibility; + final int followerCount; + final int followingCount; + final int bookCount; + final int reviewCount; + final bool isFollowing; + final bool isFriend; + final bool isBlocked; + + const CommunityUser({ + required this.userId, + this.username, + required this.displayName, + this.bio, + this.avatarUrl, + this.profileVisibility = 'public', + required this.followerCount, + required this.followingCount, + required this.bookCount, + this.reviewCount = 0, + this.isFollowing = false, + this.isFriend = false, + this.isBlocked = false, + }); + + CommunityUser copyWith({ + String? userId, + String? username, + String? displayName, + String? bio, + String? avatarUrl, + String? profileVisibility, + int? followerCount, + int? followingCount, + int? bookCount, + int? reviewCount, + bool? isFollowing, + bool? isFriend, + bool? isBlocked, + }) => CommunityUser( + userId: userId ?? this.userId, + username: username ?? this.username, + displayName: displayName ?? this.displayName, + bio: bio ?? this.bio, + avatarUrl: avatarUrl ?? this.avatarUrl, + profileVisibility: profileVisibility ?? this.profileVisibility, + followerCount: followerCount ?? this.followerCount, + followingCount: followingCount ?? this.followingCount, + bookCount: bookCount ?? this.bookCount, + reviewCount: reviewCount ?? this.reviewCount, + isFollowing: isFollowing ?? this.isFollowing, + isFriend: isFriend ?? this.isFriend, + isBlocked: isBlocked ?? this.isBlocked, + ); + + factory CommunityUser.fromJson(Map json) => CommunityUser( + userId: json['user_id'] as String, + username: json['username'] as String?, + displayName: json['display_name'] as String, + bio: json['bio'] as String?, + avatarUrl: json['avatar_url'] as String?, + profileVisibility: json['profile_visibility'] as String? ?? 'public', + followerCount: json['follower_count'] as int? ?? 0, + followingCount: json['following_count'] as int? ?? 0, + bookCount: json['book_count'] as int? ?? 0, + reviewCount: json['review_count'] as int? ?? 0, + isFollowing: json['is_following'] as bool? ?? false, + isFriend: json['is_friend'] as bool? ?? false, + isBlocked: json['is_blocked'] as bool? ?? false, + ); + + Map toJson() => { + 'user_id': userId, + 'username': username, + 'display_name': displayName, + 'bio': bio, + 'avatar_url': avatarUrl, + 'profile_visibility': profileVisibility, + 'follower_count': followerCount, + 'following_count': followingCount, + 'book_count': bookCount, + 'review_count': reviewCount, + 'is_following': isFollowing, + 'is_friend': isFriend, + 'is_blocked': isBlocked, + }; +} diff --git a/client/test/models/community_models_test.dart b/client/test/models/community_models_test.dart new file mode 100644 index 0000000..2ca9108 --- /dev/null +++ b/client/test/models/community_models_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/models/activity_item.dart'; +import 'package:papyrus/models/catalog_book.dart'; +import 'package:papyrus/models/community_review.dart'; +import 'package:papyrus/models/community_user.dart'; + +void main() { + group('CommunityUser', () { + test('creates from JSON', () { + final json = { + 'user_id': '123', + 'display_name': 'Test User', + 'username': 'testuser', + 'follower_count': 5, + 'following_count': 3, + 'book_count': 10, + }; + final user = CommunityUser.fromJson(json); + expect(user.userId, '123'); + expect(user.displayName, 'Test User'); + expect(user.username, 'testuser'); + expect(user.followerCount, 5); + }); + + test('converts to JSON', () { + const user = CommunityUser( + userId: '123', + displayName: 'Test', + followerCount: 0, + followingCount: 0, + bookCount: 0, + ); + final json = user.toJson(); + expect(json['user_id'], '123'); + expect(json['display_name'], 'Test'); + }); + + test('copyWith creates modified copy', () { + const user = CommunityUser( + userId: '123', + displayName: 'Test', + isFollowing: false, + followerCount: 5, + followingCount: 0, + bookCount: 0, + ); + final updated = user.copyWith(isFollowing: true, followerCount: 6); + expect(updated.isFollowing, true); + expect(updated.followerCount, 6); + expect(updated.displayName, 'Test'); + }); + }); + + group('CatalogBook', () { + test('creates from JSON', () { + final json = { + 'catalog_book_id': '456', + 'title': 'Dune', + 'author': 'Frank Herbert', + 'average_rating': 8.5, + 'rating_count': 100, + 'review_count': 25, + }; + final book = CatalogBook.fromJson(json); + expect(book.catalogBookId, '456'); + expect(book.title, 'Dune'); + expect(book.averageRating, 8.5); + }); + }); + + group('CommunityReview', () { + test('creates from JSON', () { + final json = { + 'review_id': '789', + 'user_id': '123', + 'catalog_book_id': '456', + 'body': 'Great book!', + 'contains_spoilers': false, + 'like_count': 3, + 'helpful_count': 1, + }; + final review = CommunityReview.fromJson(json); + expect(review.reviewId, '789'); + expect(review.body, 'Great book!'); + expect(review.containsSpoilers, false); + }); + }); + + group('ActivityItem', () { + test('creates from JSON', () { + final json = { + 'activity_id': 'act1', + 'user': {'user_id': '123', 'display_name': 'User'}, + 'activity_type': 'rated_book', + 'description': 'User rated Dune', + 'created_at': '2026-01-01T00:00:00Z', + }; + final item = ActivityItem.fromJson(json); + expect(item.activityId, 'act1'); + expect(item.activityType, 'rated_book'); + expect(item.user.displayName, 'User'); + }); + }); +} From c25d099d883cd309aff74035450ed0b8b7617722 Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 23 Feb 2026 02:52:04 +0200 Subject: [PATCH 14/17] feat(client): add community and social providers Add CommunityProvider for feed, catalog search, book details, ratings, and reviews. Add SocialProvider for user profiles, follow/unfollow, block/unblock, follow lists, and user search. Both are online-only with TODO stubs for API integration. Includes 31 tests covering initial state, loading transitions, and listener notifications. Co-Authored-By: Claude Opus 4.6 --- client/lib/providers/community_provider.dart | 156 ++++++++++++++++ client/lib/providers/social_provider.dart | 166 +++++++++++++++++ .../providers/community_provider_test.dart | 172 ++++++++++++++++++ .../test/providers/social_provider_test.dart | 150 +++++++++++++++ 4 files changed, 644 insertions(+) create mode 100644 client/lib/providers/community_provider.dart create mode 100644 client/lib/providers/social_provider.dart create mode 100644 client/test/providers/community_provider_test.dart create mode 100644 client/test/providers/social_provider_test.dart diff --git a/client/lib/providers/community_provider.dart b/client/lib/providers/community_provider.dart new file mode 100644 index 0000000..2ac07a1 --- /dev/null +++ b/client/lib/providers/community_provider.dart @@ -0,0 +1,156 @@ +import 'package:flutter/foundation.dart'; +import 'package:papyrus/models/activity_item.dart'; +import 'package:papyrus/models/catalog_book.dart'; +import 'package:papyrus/models/community_review.dart'; + +/// Provider for community features (feed, catalog, reviews). +/// Manages UI state for the community tab. Online-only. +class CommunityProvider extends ChangeNotifier { + // Feed state + List _feedItems = []; + bool _isFeedLoading = false; + String? _feedError; + + // Search state + List _searchResults = []; + String _searchQuery = ''; + bool _isSearching = false; + + // Selected book detail + CatalogBook? _selectedBook; + List _bookReviews = []; + int? _userRating; + + // Feed tab index + int _feedTabIndex = 0; + + // ============================================================================ + // GETTERS + // ============================================================================ + + List get feedItems => _feedItems; + bool get isFeedLoading => _isFeedLoading; + String? get feedError => _feedError; + bool get hasFeedItems => _feedItems.isNotEmpty; + + List get searchResults => _searchResults; + String get searchQuery => _searchQuery; + bool get isSearching => _isSearching; + bool get hasSearchResults => _searchResults.isNotEmpty; + + CatalogBook? get selectedBook => _selectedBook; + List get bookReviews => _bookReviews; + int? get userRating => _userRating; + + int get feedTabIndex => _feedTabIndex; + + // ============================================================================ + // FEED + // ============================================================================ + + /// Set the active feed tab index. + void setFeedTabIndex(int index) { + _feedTabIndex = index; + notifyListeners(); + } + + /// Load the following feed (people the user follows). + Future loadFeed() async { + _isFeedLoading = true; + _feedError = null; + notifyListeners(); + + // TODO: Call ApiClient.get('/v1/feed') and parse response + _feedItems = []; + _isFeedLoading = false; + notifyListeners(); + } + + /// Load the global/discover feed. + Future loadGlobalFeed() async { + _isFeedLoading = true; + _feedError = null; + notifyListeners(); + + // TODO: Call ApiClient.get('/v1/feed/global') and parse response + _feedItems = []; + _isFeedLoading = false; + notifyListeners(); + } + + // ============================================================================ + // SEARCH + // ============================================================================ + + /// Search the community book catalog by query. + Future searchBooks(String query) async { + _searchQuery = query; + if (query.isEmpty) { + _searchResults = []; + _isSearching = false; + notifyListeners(); + return; + } + + _isSearching = true; + notifyListeners(); + + // TODO: Call ApiClient.get('/v1/catalog/search', queryParams: {'q': query}) + _searchResults = []; + _isSearching = false; + notifyListeners(); + } + + /// Clear the search query and results. + void clearSearch() { + _searchQuery = ''; + _searchResults = []; + _isSearching = false; + notifyListeners(); + } + + // ============================================================================ + // BOOK DETAILS + // ============================================================================ + + /// Select a book to view its details. + void selectBook(CatalogBook book) { + _selectedBook = book; + _bookReviews = []; + _userRating = null; + notifyListeners(); + } + + /// Load reviews for a catalog book. + Future loadBookReviews(String catalogBookId) async { + // TODO: Call ApiClient.get('/v1/catalog/books/$catalogBookId/reviews') + _bookReviews = []; + notifyListeners(); + } + + // ============================================================================ + // RATINGS + // ============================================================================ + + /// Rate a catalog book with a score. + Future rateBook(String catalogBookId, int score) async { + _userRating = score; + notifyListeners(); + // TODO: Call ApiClient.post('/v1/ratings', body: {...}) + } + + // ============================================================================ + // REVIEWS + // ============================================================================ + + /// Submit a review for a catalog book. + Future submitReview({ + required String catalogBookId, + required String body, + String? title, + bool containsSpoilers = false, + }) async { + // TODO: Call ApiClient.post('/v1/reviews', body: {...}) + notifyListeners(); + } +} diff --git a/client/lib/providers/social_provider.dart b/client/lib/providers/social_provider.dart new file mode 100644 index 0000000..f437ad7 --- /dev/null +++ b/client/lib/providers/social_provider.dart @@ -0,0 +1,166 @@ +import 'package:flutter/foundation.dart'; +import 'package:papyrus/models/community_user.dart'; + +/// Provider for social features (follow, block, user profiles). +/// Online-only — no offline caching. +class SocialProvider extends ChangeNotifier { + // Profile state + CommunityUser? _currentProfile; + CommunityUser? _viewedProfile; + bool _isProfileLoading = false; + + // Follow lists + List _followers = []; + List _following = []; + List _friends = []; + bool _isFollowListLoading = false; + + // Discover (user search) + List _userSearchResults = []; + bool _isUserSearching = false; + + // ============================================================================ + // GETTERS + // ============================================================================ + + CommunityUser? get currentProfile => _currentProfile; + CommunityUser? get viewedProfile => _viewedProfile; + bool get isProfileLoading => _isProfileLoading; + + List get followers => _followers; + List get following => _following; + List get friends => _friends; + bool get isFollowListLoading => _isFollowListLoading; + + List get userSearchResults => _userSearchResults; + bool get isUserSearching => _isUserSearching; + + // ============================================================================ + // PROFILE + // ============================================================================ + + /// Load the current user's community profile. + Future loadOwnProfile() async { + _isProfileLoading = true; + notifyListeners(); + + // TODO: Call ApiClient.get('/v1/profiles/me') + _isProfileLoading = false; + notifyListeners(); + } + + /// Load another user's community profile. + Future loadUserProfile(String userId) async { + _isProfileLoading = true; + _viewedProfile = null; + notifyListeners(); + + // TODO: Call ApiClient.get('/v1/profiles/$userId') + _isProfileLoading = false; + notifyListeners(); + } + + // ============================================================================ + // FOLLOW / UNFOLLOW + // ============================================================================ + + /// Follow a user. Optimistically updates the viewed profile if it matches. + Future followUser(String targetUserId) async { + // TODO: Call ApiClient.post('/v1/social/follow/$targetUserId') + // Optimistically update viewedProfile if it matches + if (_viewedProfile != null && _viewedProfile!.userId == targetUserId) { + _viewedProfile = _viewedProfile!.copyWith( + isFollowing: true, + followerCount: _viewedProfile!.followerCount + 1, + ); + notifyListeners(); + } + } + + /// Unfollow a user. Optimistically updates the viewed profile if it matches. + Future unfollowUser(String targetUserId) async { + // TODO: Call ApiClient.delete('/v1/social/follow/$targetUserId') + if (_viewedProfile != null && _viewedProfile!.userId == targetUserId) { + _viewedProfile = _viewedProfile!.copyWith( + isFollowing: false, + followerCount: _viewedProfile!.followerCount - 1, + ); + notifyListeners(); + } + } + + // ============================================================================ + // BLOCK + // ============================================================================ + + /// Block a user. + Future blockUser(String targetUserId) async { + // TODO: Call ApiClient.post('/v1/social/block/$targetUserId') + notifyListeners(); + } + + /// Unblock a user. + Future unblockUser(String targetUserId) async { + // TODO: Call ApiClient.delete('/v1/social/block/$targetUserId') + notifyListeners(); + } + + // ============================================================================ + // FOLLOW LISTS + // ============================================================================ + + /// Load the current user's followers. + Future loadFollowers() async { + _isFollowListLoading = true; + notifyListeners(); + + // TODO: Call ApiClient.get('/v1/social/followers') + _followers = []; + _isFollowListLoading = false; + notifyListeners(); + } + + /// Load the users the current user is following. + Future loadFollowing() async { + _isFollowListLoading = true; + notifyListeners(); + + // TODO: Call ApiClient.get('/v1/social/following') + _following = []; + _isFollowListLoading = false; + notifyListeners(); + } + + /// Load the current user's mutual-follow friends. + Future loadFriends() async { + _isFollowListLoading = true; + notifyListeners(); + + // TODO: Call ApiClient.get('/v1/social/friends') + _friends = []; + _isFollowListLoading = false; + notifyListeners(); + } + + // ============================================================================ + // USER SEARCH + // ============================================================================ + + /// Search for users by query string. + Future searchUsers(String query) async { + if (query.isEmpty) { + _userSearchResults = []; + _isUserSearching = false; + notifyListeners(); + return; + } + + _isUserSearching = true; + notifyListeners(); + + // TODO: Call search endpoint + _userSearchResults = []; + _isUserSearching = false; + notifyListeners(); + } +} diff --git a/client/test/providers/community_provider_test.dart b/client/test/providers/community_provider_test.dart new file mode 100644 index 0000000..c4bbd12 --- /dev/null +++ b/client/test/providers/community_provider_test.dart @@ -0,0 +1,172 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/models/catalog_book.dart'; +import 'package:papyrus/providers/community_provider.dart'; + +void main() { + group('CommunityProvider', () { + late CommunityProvider provider; + + setUp(() { + provider = CommunityProvider(); + }); + + group('initial state', () { + test('should have empty feed', () { + expect(provider.feedItems, isEmpty); + expect(provider.isFeedLoading, false); + expect(provider.feedError, isNull); + expect(provider.hasFeedItems, false); + }); + + test('should have empty search', () { + expect(provider.searchResults, isEmpty); + expect(provider.searchQuery, ''); + expect(provider.isSearching, false); + expect(provider.hasSearchResults, false); + }); + + test('should have no selected book', () { + expect(provider.selectedBook, isNull); + expect(provider.bookReviews, isEmpty); + expect(provider.userRating, isNull); + }); + + test('should have feed tab index 0', () { + expect(provider.feedTabIndex, 0); + }); + }); + + group('feed tab', () { + test('should update feed tab index', () { + provider.setFeedTabIndex(1); + expect(provider.feedTabIndex, 1); + }); + + test('should notify listeners on tab change', () { + var notified = false; + provider.addListener(() => notified = true); + + provider.setFeedTabIndex(2); + expect(notified, true); + }); + }); + + group('feed loading', () { + test('should set loading state during loadFeed', () async { + var loadingStates = []; + provider.addListener(() { + loadingStates.add(provider.isFeedLoading); + }); + + await provider.loadFeed(); + + expect(loadingStates, [true, false]); + expect(provider.feedError, isNull); + }); + + test('should set loading state during loadGlobalFeed', () async { + var loadingStates = []; + provider.addListener(() { + loadingStates.add(provider.isFeedLoading); + }); + + await provider.loadGlobalFeed(); + + expect(loadingStates, [true, false]); + expect(provider.feedError, isNull); + }); + }); + + group('search', () { + test('should clear results for empty query', () async { + await provider.searchBooks(''); + expect(provider.searchQuery, ''); + expect(provider.searchResults, isEmpty); + expect(provider.isSearching, false); + }); + + test('should set searching state for non-empty query', () async { + var searchingStates = []; + provider.addListener(() { + searchingStates.add(provider.isSearching); + }); + + await provider.searchBooks('flutter'); + + expect(provider.searchQuery, 'flutter'); + expect(searchingStates, [true, false]); + }); + + test('should clear search state', () { + provider.clearSearch(); + expect(provider.searchQuery, ''); + expect(provider.searchResults, isEmpty); + expect(provider.isSearching, false); + }); + + test('should notify listeners on clearSearch', () { + var notified = false; + provider.addListener(() => notified = true); + + provider.clearSearch(); + expect(notified, true); + }); + }); + + group('book details', () { + test('should set selected book and clear reviews', () { + const book = CatalogBook( + catalogBookId: '123', + title: 'Test Book', + author: 'Test Author', + ); + provider.selectBook(book); + + expect(provider.selectedBook, book); + expect(provider.bookReviews, isEmpty); + expect(provider.userRating, isNull); + }); + + test('should notify listeners on selectBook', () { + var notified = false; + provider.addListener(() => notified = true); + + const book = CatalogBook( + catalogBookId: '456', + title: 'Another Book', + author: 'Another Author', + ); + provider.selectBook(book); + expect(notified, true); + }); + }); + + group('ratings', () { + test('should update user rating', () async { + await provider.rateBook('book-1', 8); + expect(provider.userRating, 8); + }); + + test('should notify listeners on rateBook', () async { + var notified = false; + provider.addListener(() => notified = true); + + await provider.rateBook('book-1', 5); + expect(notified, true); + }); + }); + + group('reviews', () { + test('should notify listeners on submitReview', () async { + var notified = false; + provider.addListener(() => notified = true); + + await provider.submitReview( + catalogBookId: 'book-1', + body: 'Great book!', + ); + expect(notified, true); + }); + }); + }); +} diff --git a/client/test/providers/social_provider_test.dart b/client/test/providers/social_provider_test.dart new file mode 100644 index 0000000..5702f0e --- /dev/null +++ b/client/test/providers/social_provider_test.dart @@ -0,0 +1,150 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/providers/social_provider.dart'; + +void main() { + group('SocialProvider', () { + late SocialProvider provider; + + setUp(() { + provider = SocialProvider(); + }); + + group('initial state', () { + test('should have null profiles', () { + expect(provider.currentProfile, isNull); + expect(provider.viewedProfile, isNull); + expect(provider.isProfileLoading, false); + }); + + test('should have empty follow lists', () { + expect(provider.followers, isEmpty); + expect(provider.following, isEmpty); + expect(provider.friends, isEmpty); + expect(provider.isFollowListLoading, false); + }); + + test('should have empty user search', () { + expect(provider.userSearchResults, isEmpty); + expect(provider.isUserSearching, false); + }); + }); + + group('profile loading', () { + test('should set loading state during loadOwnProfile', () async { + var loadingStates = []; + provider.addListener(() { + loadingStates.add(provider.isProfileLoading); + }); + + await provider.loadOwnProfile(); + + expect(loadingStates, [true, false]); + }); + + test( + 'should clear viewed profile and set loading during loadUserProfile', + () async { + var loadingStates = []; + provider.addListener(() { + loadingStates.add(provider.isProfileLoading); + }); + + await provider.loadUserProfile('user-123'); + + expect(provider.viewedProfile, isNull); + expect(loadingStates, [true, false]); + }, + ); + }); + + group('follow / unfollow', () { + test('should not crash when following with no viewed profile', () async { + await provider.followUser('user-123'); + // No assertion error = pass + }); + + test( + 'should not crash when unfollowing with no viewed profile', + () async { + await provider.unfollowUser('user-123'); + // No assertion error = pass + }, + ); + }); + + group('block / unblock', () { + test('should notify listeners on blockUser', () async { + var notified = false; + provider.addListener(() => notified = true); + + await provider.blockUser('user-456'); + expect(notified, true); + }); + + test('should notify listeners on unblockUser', () async { + var notified = false; + provider.addListener(() => notified = true); + + await provider.unblockUser('user-456'); + expect(notified, true); + }); + }); + + group('follow lists', () { + test('should set loading state during loadFollowers', () async { + var loadingStates = []; + provider.addListener(() { + loadingStates.add(provider.isFollowListLoading); + }); + + await provider.loadFollowers(); + + expect(loadingStates, [true, false]); + expect(provider.followers, isEmpty); + }); + + test('should set loading state during loadFollowing', () async { + var loadingStates = []; + provider.addListener(() { + loadingStates.add(provider.isFollowListLoading); + }); + + await provider.loadFollowing(); + + expect(loadingStates, [true, false]); + expect(provider.following, isEmpty); + }); + + test('should set loading state during loadFriends', () async { + var loadingStates = []; + provider.addListener(() { + loadingStates.add(provider.isFollowListLoading); + }); + + await provider.loadFriends(); + + expect(loadingStates, [true, false]); + expect(provider.friends, isEmpty); + }); + }); + + group('user search', () { + test('should clear results for empty query', () async { + await provider.searchUsers(''); + expect(provider.userSearchResults, isEmpty); + expect(provider.isUserSearching, false); + }); + + test('should set searching state for non-empty query', () async { + var searchingStates = []; + provider.addListener(() { + searchingStates.add(provider.isUserSearching); + }); + + await provider.searchUsers('alice'); + + expect(searchingStates, [true, false]); + }); + }); + }); +} From 923a5b53ab553005412348c0a8495a022367396e Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 23 Feb 2026 02:56:59 +0200 Subject: [PATCH 15/17] feat(client): add community tab, pages, and navigation Co-Authored-By: Claude Opus 4.6 --- client/lib/config/app_router.dart | 48 ++++++++++++ client/lib/main.dart | 4 + client/lib/pages/community_book_page.dart | 42 +++++++++++ client/lib/pages/community_page.dart | 55 ++++++++++++++ client/lib/pages/user_profile_page.dart | 46 ++++++++++++ client/lib/pages/write_review_page.dart | 52 +++++++++++++ .../lib/widgets/community/activity_card.dart | 50 +++++++++++++ .../widgets/community/activity_feed_list.dart | 73 +++++++++++++++++++ .../widgets/community/discover_content.dart | 55 ++++++++++++++ .../lib/widgets/shell/adaptive_app_shell.dart | 6 ++ 10 files changed, 431 insertions(+) create mode 100644 client/lib/pages/community_book_page.dart create mode 100644 client/lib/pages/community_page.dart create mode 100644 client/lib/pages/user_profile_page.dart create mode 100644 client/lib/pages/write_review_page.dart create mode 100644 client/lib/widgets/community/activity_card.dart create mode 100644 client/lib/widgets/community/activity_feed_list.dart create mode 100644 client/lib/widgets/community/discover_content.dart diff --git a/client/lib/config/app_router.dart b/client/lib/config/app_router.dart index 7631920..f234e37 100644 --- a/client/lib/config/app_router.dart +++ b/client/lib/config/app_router.dart @@ -22,7 +22,11 @@ import 'package:papyrus/pages/shelves_page.dart'; import 'package:papyrus/pages/statistics_page.dart'; import 'package:papyrus/pages/annotations_page.dart'; import 'package:papyrus/pages/notes_page.dart'; +import 'package:papyrus/pages/community_book_page.dart'; +import 'package:papyrus/pages/community_page.dart'; +import 'package:papyrus/pages/user_profile_page.dart'; import 'package:papyrus/pages/welcome_page.dart'; +import 'package:papyrus/pages/write_review_page.dart'; import 'package:papyrus/widgets/shell/adaptive_app_shell.dart'; class AppRouter { @@ -179,6 +183,50 @@ class AppRouter { ), ], ), + // Community + GoRoute( + name: 'COMMUNITY', + path: '/community', + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: const CommunityPage(), + ), + routes: [ + GoRoute( + name: 'USER_PROFILE', + path: 'user/:userId', + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: UserProfilePage( + userId: state.pathParameters['userId'], + ), + ), + ), + GoRoute( + name: 'COMMUNITY_BOOK', + path: 'book/:catalogBookId', + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: CommunityBookPage( + catalogBookId: state.pathParameters['catalogBookId'], + ), + ), + routes: [ + GoRoute( + name: 'WRITE_REVIEW', + path: 'review', + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: WriteReviewPage( + catalogBookId: state.pathParameters['catalogBookId'], + bookTitle: state.uri.queryParameters['title'], + ), + ), + ), + ], + ), + ], + ), // Goals GoRoute( name: 'GOALS', diff --git a/client/lib/main.dart b/client/lib/main.dart index 2a57fc4..dcba16e 100644 --- a/client/lib/main.dart +++ b/client/lib/main.dart @@ -2,9 +2,11 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/data/sample_data.dart'; +import 'package:papyrus/providers/community_provider.dart'; import 'package:papyrus/providers/display_mode_provider.dart'; import 'package:papyrus/providers/google_sign_in_provider.dart'; import 'package:papyrus/providers/library_provider.dart'; +import 'package:papyrus/providers/social_provider.dart'; import 'package:papyrus/providers/preferences_provider.dart'; import 'package:papyrus/providers/sidebar_provider.dart'; import 'package:papyrus/themes/app_theme.dart'; @@ -62,6 +64,8 @@ class _PapyrusState extends State { ChangeNotifierProvider( create: (_) => PreferencesProvider(widget.prefs), ), + ChangeNotifierProvider(create: (_) => CommunityProvider()), + ChangeNotifierProvider(create: (_) => SocialProvider()), ], child: Consumer2( builder: (context, displayModeProvider, preferencesProvider, child) { diff --git a/client/lib/pages/community_book_page.dart b/client/lib/pages/community_book_page.dart new file mode 100644 index 0000000..50262f7 --- /dev/null +++ b/client/lib/pages/community_book_page.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/themes/design_tokens.dart'; + +/// Community book detail page showing ratings and reviews. +class CommunityBookPage extends StatelessWidget { + final String? catalogBookId; + + const CommunityBookPage({super.key, this.catalogBookId}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar(title: const Text('Book details')), + body: Center( + child: Padding( + padding: const EdgeInsets.all(Spacing.lg), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.menu_book, size: 64, color: theme.colorScheme.primary), + const SizedBox(height: Spacing.md), + Text( + 'Community book details', + style: theme.textTheme.headlineSmall, + ), + const SizedBox(height: Spacing.sm), + Text( + 'Ratings, reviews, and book info will appear here.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ); + } +} diff --git a/client/lib/pages/community_page.dart b/client/lib/pages/community_page.dart new file mode 100644 index 0000000..f9b5681 --- /dev/null +++ b/client/lib/pages/community_page.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/providers/community_provider.dart'; +import 'package:papyrus/widgets/community/activity_feed_list.dart'; +import 'package:papyrus/widgets/community/discover_content.dart'; +import 'package:provider/provider.dart'; + +/// Main community page with Feed and Discover tabs. +class CommunityPage extends StatefulWidget { + const CommunityPage({super.key}); + + @override + State createState() => _CommunityPageState(); +} + +class _CommunityPageState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + _tabController.addListener(() { + if (!_tabController.indexIsChanging) { + context.read().setFeedTabIndex(_tabController.index); + } + }); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Community'), + bottom: TabBar( + controller: _tabController, + tabs: const [ + Tab(text: 'Feed'), + Tab(text: 'Discover'), + ], + ), + ), + body: TabBarView( + controller: _tabController, + children: const [ActivityFeedList(), DiscoverContent()], + ), + ); + } +} diff --git a/client/lib/pages/user_profile_page.dart b/client/lib/pages/user_profile_page.dart new file mode 100644 index 0000000..1867899 --- /dev/null +++ b/client/lib/pages/user_profile_page.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/themes/design_tokens.dart'; + +/// Community user profile page. +class UserProfilePage extends StatelessWidget { + final String? userId; + + const UserProfilePage({super.key, this.userId}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar(title: const Text('Profile')), + body: Center( + child: Padding( + padding: const EdgeInsets.all(Spacing.lg), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircleAvatar( + radius: 48, + backgroundColor: theme.colorScheme.primaryContainer, + child: Icon( + Icons.person, + size: IconSizes.large, + color: theme.colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(height: Spacing.md), + Text('User profile', style: theme.textTheme.headlineSmall), + const SizedBox(height: Spacing.sm), + Text( + 'Profile details will be loaded from the API.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/client/lib/pages/write_review_page.dart b/client/lib/pages/write_review_page.dart new file mode 100644 index 0000000..a93fb1a --- /dev/null +++ b/client/lib/pages/write_review_page.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/themes/design_tokens.dart'; + +/// Page for writing a book review. +class WriteReviewPage extends StatelessWidget { + final String? catalogBookId; + final String? bookTitle; + + const WriteReviewPage({super.key, this.catalogBookId, this.bookTitle}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: Text(bookTitle != null ? 'Review: $bookTitle' : 'Write review'), + ), + body: Padding( + padding: const EdgeInsets.all(Spacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Your rating', style: theme.textTheme.titleMedium), + const SizedBox(height: Spacing.sm), + Text( + 'Rating selection will be implemented with the API.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: Spacing.lg), + Text('Your review', style: theme.textTheme.titleMedium), + const SizedBox(height: Spacing.sm), + const Expanded( + child: TextField( + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + decoration: InputDecoration( + hintText: 'Write your review here...', + border: OutlineInputBorder(), + alignLabelWithHint: true, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/client/lib/widgets/community/activity_card.dart b/client/lib/widgets/community/activity_card.dart new file mode 100644 index 0000000..c19abc9 --- /dev/null +++ b/client/lib/widgets/community/activity_card.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/models/activity_item.dart'; +import 'package:papyrus/themes/design_tokens.dart'; + +/// Card displaying a single activity feed item. +class ActivityCard extends StatelessWidget { + final ActivityItem activity; + + const ActivityCard({super.key, required this.activity}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Card( + child: Padding( + padding: const EdgeInsets.all(Spacing.md), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 20, + backgroundColor: theme.colorScheme.primaryContainer, + child: Text( + activity.user.displayName.isNotEmpty + ? activity.user.displayName[0].toUpperCase() + : '?', + style: TextStyle(color: theme.colorScheme.onPrimaryContainer), + ), + ), + const SizedBox(width: Spacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + activity.user.displayName, + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: Spacing.xs), + Text(activity.description, style: theme.textTheme.bodyMedium), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/client/lib/widgets/community/activity_feed_list.dart b/client/lib/widgets/community/activity_feed_list.dart new file mode 100644 index 0000000..fad21e4 --- /dev/null +++ b/client/lib/widgets/community/activity_feed_list.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/providers/community_provider.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/community/activity_card.dart'; +import 'package:provider/provider.dart'; + +/// List of activity feed items. +class ActivityFeedList extends StatelessWidget { + const ActivityFeedList({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Consumer( + builder: (context, provider, child) { + if (provider.isFeedLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (provider.feedError != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: IconSizes.large, + color: theme.colorScheme.error, + ), + const SizedBox(height: Spacing.sm), + Text(provider.feedError!, style: theme.textTheme.bodyMedium), + ], + ), + ); + } + + if (!provider.hasFeedItems) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.people_outlined, + size: IconSizes.large, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: Spacing.md), + Text('No activity yet', style: theme.textTheme.titleMedium), + const SizedBox(height: Spacing.xs), + Text( + 'Follow other readers to see their activity here.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + return ListView.separated( + padding: const EdgeInsets.all(Spacing.md), + itemCount: provider.feedItems.length, + separatorBuilder: (_, _) => const SizedBox(height: Spacing.sm), + itemBuilder: (context, index) { + return ActivityCard(activity: provider.feedItems[index]); + }, + ); + }, + ); + } +} diff --git a/client/lib/widgets/community/discover_content.dart b/client/lib/widgets/community/discover_content.dart new file mode 100644 index 0000000..72ed8a1 --- /dev/null +++ b/client/lib/widgets/community/discover_content.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/themes/design_tokens.dart'; + +/// Discover tab content with search for books and users. +class DiscoverContent extends StatelessWidget { + const DiscoverContent({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.all(Spacing.md), + child: Column( + children: [ + SearchBar( + hintText: 'Search books or readers...', + leading: const Icon(Icons.search), + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: Spacing.md), + ), + ), + const SizedBox(height: Spacing.lg), + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.explore_outlined, + size: IconSizes.large, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: Spacing.md), + Text( + 'Discover books and readers', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: Spacing.xs), + Text( + 'Search for books to rate and review, or find readers to follow.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/client/lib/widgets/shell/adaptive_app_shell.dart b/client/lib/widgets/shell/adaptive_app_shell.dart index 73c8f8a..d3da972 100644 --- a/client/lib/widgets/shell/adaptive_app_shell.dart +++ b/client/lib/widgets/shell/adaptive_app_shell.dart @@ -78,6 +78,12 @@ class AdaptiveAppShell extends StatelessWidget { ), ], ), + AppShellNavItem( + path: '/community', + label: 'Community', + icon: Icons.people_outlined, + selectedIcon: Icons.people, + ), AppShellNavItem( path: '/goals', label: 'Goals', From d048224932f310f4fd2dd8e86c2ce77e3b410b93 Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 23 Feb 2026 02:58:14 +0200 Subject: [PATCH 16/17] style(server): fix import ordering in test_catalog.py Co-Authored-By: Claude Opus 4.6 --- server/tests/test_catalog.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/server/tests/test_catalog.py b/server/tests/test_catalog.py index eb46bcc..866be14 100644 --- a/server/tests/test_catalog.py +++ b/server/tests/test_catalog.py @@ -43,9 +43,7 @@ def test_get_book_reviews(client: TestClient, auth_headers: dict[str, str]): def test_get_ratings_distribution(client: TestClient, auth_headers: dict[str, str]): book_id = str(uuid4()) - response = client.get( - f"/v1/catalog/books/{book_id}/ratings/distribution", headers=auth_headers - ) + response = client.get(f"/v1/catalog/books/{book_id}/ratings/distribution", headers=auth_headers) assert response.status_code == 200 data = response.json() assert "catalog_book_id" in data From 21f1040a068e52a965596964bb830e24de91c652 Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 23 Feb 2026 03:14:31 +0200 Subject: [PATCH 17/17] fix: address code review issues - Import models in Alembic env.py so autogenerate detects tables - Make ApiResponse.json/jsonList nullable for 204 No Content responses - Add reaction_type path param to remove_reaction endpoint Co-Authored-By: Claude Opus 4.6 --- client/lib/services/api_client.dart | 6 ++++-- server/alembic/env.py | 3 ++- server/src/papyrus/api/routes/reviews.py | 6 +++--- server/tests/test_reviews.py | 2 +- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/client/lib/services/api_client.dart b/client/lib/services/api_client.dart index cc0173d..ec86f57 100644 --- a/client/lib/services/api_client.dart +++ b/client/lib/services/api_client.dart @@ -108,7 +108,9 @@ class ApiResponse { bool get isSuccess => statusCode >= 200 && statusCode < 300; - Map get json => jsonDecode(body) as Map; + Map? get json => + body.isNotEmpty ? jsonDecode(body) as Map : null; - List get jsonList => jsonDecode(body) as List; + List? get jsonList => + body.isNotEmpty ? jsonDecode(body) as List : null; } diff --git a/server/alembic/env.py b/server/alembic/env.py index 49da7f5..0fafdbe 100644 --- a/server/alembic/env.py +++ b/server/alembic/env.py @@ -1,10 +1,11 @@ import asyncio from logging.config import fileConfig -from alembic import context from sqlalchemy import pool from sqlalchemy.ext.asyncio import async_engine_from_config +import papyrus.models # noqa: F401 — register models with Base.metadata +from alembic import context from papyrus.config import get_settings from papyrus.core.database import Base diff --git a/server/src/papyrus/api/routes/reviews.py b/server/src/papyrus/api/routes/reviews.py index efbc9b5..366b05b 100644 --- a/server/src/papyrus/api/routes/reviews.py +++ b/server/src/papyrus/api/routes/reviews.py @@ -77,7 +77,7 @@ async def react_to_review( return Response(status_code=status.HTTP_204_NO_CONTENT) -@router.delete("/{review_id}/react", status_code=status.HTTP_204_NO_CONTENT) -async def remove_reaction(user_id: CurrentUserId, review_id: UUID) -> Response: - """Remove a reaction from a review.""" +@router.delete("/{review_id}/react/{reaction_type}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_reaction(user_id: CurrentUserId, review_id: UUID, reaction_type: str) -> Response: + """Remove a specific reaction from a review.""" return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/server/tests/test_reviews.py b/server/tests/test_reviews.py index aa5795c..1e4fe1c 100644 --- a/server/tests/test_reviews.py +++ b/server/tests/test_reviews.py @@ -58,7 +58,7 @@ def test_react_to_review(client: TestClient, auth_headers: dict[str, str]): def test_remove_reaction(client: TestClient, auth_headers: dict[str, str]): review_id = str(uuid4()) - response = client.delete(f"/v1/reviews/{review_id}/react", headers=auth_headers) + response = client.delete(f"/v1/reviews/{review_id}/react/like", headers=auth_headers) assert response.status_code == 204