diff --git a/docs/superpowers/plans/2026-05-18-spark-backend-account-reviews-sync.md b/docs/superpowers/plans/2026-05-18-spark-backend-account-reviews-sync.md new file mode 100644 index 00000000..6e774794 --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-spark-backend-account-reviews-sync.md @@ -0,0 +1,1668 @@ +# Spark Backend Account Reviews Sync Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the new `spark-store-backend` FastAPI + MySQL service for Flarum-backed login, app reviews/ratings, and default user app-list sync. + +**Architecture:** The backend validates Flarum access tokens, maps Flarum users to local users, signs Spark Store JWTs, and persists Spark Store-specific data in MySQL. The API uses synchronous FastAPI routes, SQLAlchemy ORM, Alembic migrations, and pytest API tests with a SQLite test database. + +**Tech Stack:** Python 3.11+, FastAPI, SQLAlchemy 2.x, Alembic, PyMySQL, Pydantic Settings, python-jose, httpx, pytest. + +--- + +## File Structure + +Create a new sibling repository at `/home/spark/Desktop/shenmo-spark-store/spark-store-backend`. + +Backend files: + +- Create: `README.md` - project overview, setup, test commands, API summary. +- Create: `.gitignore` - Python, virtualenv, test, and secret ignores. +- Create: `.env.example` - safe configuration template. +- Create: `pyproject.toml` - dependencies, pytest config, tooling config. +- Create: `alembic.ini` - Alembic config. +- Create: `alembic/env.py` - migration environment wired to app metadata. +- Create: `alembic/versions/0001_initial.py` - initial MySQL schema. +- Create: `app/main.py` - FastAPI app factory and router registration. +- Create: `app/core/config.py` - environment settings. +- Create: `app/core/security.py` - JWT creation and decoding. +- Create: `app/db/session.py` - SQLAlchemy engine/session dependency. +- Create: `app/db/base.py` - declarative base and model imports. +- Create: `app/models/user.py` - `User` ORM model. +- Create: `app/models/store_app.py` - `StoreApp` ORM model. +- Create: `app/models/review.py` - `Review` ORM model. +- Create: `app/models/app_list.py` - app-list ORM models. +- Create: `app/schemas/auth.py` - auth request/response schemas. +- Create: `app/schemas/review.py` - review request/response schemas. +- Create: `app/schemas/app_list.py` - app-list request/response schemas. +- Create: `app/services/flarum.py` - Flarum profile validation client. +- Create: `app/api/deps.py` - auth and DB dependencies. +- Create: `app/api/router.py` - API router aggregator. +- Create: `app/api/routes/auth.py` - auth endpoints. +- Create: `app/api/routes/reviews.py` - review and rating endpoints. +- Create: `app/api/routes/app_lists.py` - app-list endpoints. +- Create: `tests/conftest.py` - test app and database fixtures. +- Create: `tests/test_auth.py` - Flarum auth tests. +- Create: `tests/test_reviews.py` - review behavior tests. +- Create: `tests/test_app_lists.py` - app-list sync tests. + +## Task 1: Initialize Backend Repository + +**Files:** +- Create: `/home/spark/Desktop/shenmo-spark-store/spark-store-backend/README.md` +- Create: `/home/spark/Desktop/shenmo-spark-store/spark-store-backend/.gitignore` + +- [ ] **Step 1: Verify parent directory exists** + +Run: `ls "/home/spark/Desktop/shenmo-spark-store"` + +Expected: output includes `spark-store`. + +- [ ] **Step 2: Create repository directory** + +Run: `mkdir "/home/spark/Desktop/shenmo-spark-store/spark-store-backend"` + +Expected: command exits 0. + +- [ ] **Step 3: Initialize Git repository** + +Run: `git init` + +Workdir: `/home/spark/Desktop/shenmo-spark-store/spark-store-backend` + +Expected: output says an empty Git repository was initialized. + +- [ ] **Step 4: Write initial README** + +Create `README.md` with: + +```markdown +# Spark Store Backend + +Python backend for Spark Store account login, app reviews, ratings, and user app-list sync. + +The service uses the Spark forum Flarum instance as the identity provider and stores Spark Store-specific data in MySQL. + +## Development + +```bash +python -m venv .venv +. .venv/bin/activate +pip install -e ".[dev]" +pytest +``` +``` + +- [ ] **Step 5: Write `.gitignore`** + +Create `.gitignore` with: + +```gitignore +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.coverage +htmlcov/ +.env +*.sqlite3 +dist/ +build/ +*.egg-info/ +``` + +- [ ] **Step 6: Commit initial repository** + +Run: + +```bash +git add README.md .gitignore +git commit -m "first commit" +git remote add origin https://gitee.com/momen_official/spark-store-backend.git +``` + +Expected: commit succeeds and `git remote -v` shows the Gitee origin. + +## Task 2: Add Backend Project Skeleton + +**Files:** +- Create: `pyproject.toml` +- Create: `.env.example` +- Create: `app/__init__.py` +- Create: `app/main.py` +- Create: `app/core/config.py` +- Create: `app/api/router.py` +- Test: `tests/test_health.py` + +- [ ] **Step 1: Write failing health test** + +Create `tests/test_health.py` with: + +```python +from fastapi.testclient import TestClient + +from app.main import create_app + + +def test_health_returns_ok(): + client = TestClient(create_app()) + + response = client.get("/health") + + assert response.status_code == 200 + assert response.json() == {"status": "ok"} +``` + +- [ ] **Step 2: Run test and verify failure** + +Run: `pytest tests/test_health.py -v` + +Expected: FAIL with `ModuleNotFoundError: No module named 'app'`. + +- [ ] **Step 3: Add package config** + +Create `pyproject.toml` with: + +```toml +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[project] +name = "spark-store-backend" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115,<1.0", + "uvicorn[standard]>=0.30,<1.0", + "sqlalchemy>=2.0,<3.0", + "alembic>=1.13,<2.0", + "pymysql>=1.1,<2.0", + "pydantic-settings>=2.4,<3.0", + "python-jose[cryptography]>=3.3,<4.0", + "httpx>=0.27,<1.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3,<9.0", + "pytest-cov>=5.0,<6.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] +``` + +- [ ] **Step 4: Add settings** + +Create `.env.example` with: + +```env +APP_NAME="Spark Store Backend" +DATABASE_URL="mysql+pymysql://spark:spark_password@127.0.0.1:3306/spark_store" +JWT_SECRET_KEY="change-me-in-production" +JWT_ALGORITHM="HS256" +JWT_EXPIRE_MINUTES="10080" +FLARUM_BASE_URL="https://bbs.spark-app.store" +``` + +Create `app/core/config.py` with: + +```python +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + app_name: str = "Spark Store Backend" + database_url: str = "sqlite:///./spark-store-dev.sqlite3" + jwt_secret_key: str = "dev-secret" + jwt_algorithm: str = "HS256" + jwt_expire_minutes: int = 10080 + flarum_base_url: str = "https://bbs.spark-app.store" + + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") + + +@lru_cache +def get_settings() -> Settings: + return Settings() +``` + +- [ ] **Step 5: Add FastAPI app** + +Create `app/__init__.py` as an empty file. + +Create `app/api/router.py` with: + +```python +from fastapi import APIRouter + +api_router = APIRouter() +``` + +Create `app/main.py` with: + +```python +from fastapi import FastAPI + +from app.api.router import api_router +from app.core.config import get_settings + + +def create_app() -> FastAPI: + settings = get_settings() + app = FastAPI(title=settings.app_name) + + @app.get("/health") + def health() -> dict[str, str]: + return {"status": "ok"} + + app.include_router(api_router) + return app + + +app = create_app() +``` + +- [ ] **Step 6: Run test and verify pass** + +Run: `pytest tests/test_health.py -v` + +Expected: PASS. + +- [ ] **Step 7: Commit skeleton** + +Run: + +```bash +git add . +git commit -m "feat: add FastAPI backend skeleton" +``` + +Expected: commit succeeds. + +## Task 3: Add Database Models And Migration + +**Files:** +- Create: `app/db/session.py` +- Create: `app/db/base.py` +- Create: `app/models/user.py` +- Create: `app/models/store_app.py` +- Create: `app/models/review.py` +- Create: `app/models/app_list.py` +- Create: `alembic.ini` +- Create: `alembic/env.py` +- Create: `alembic/versions/0001_initial.py` +- Test: `tests/test_models.py` + +- [ ] **Step 1: Write failing model test** + +Create `tests/test_models.py` with: + +```python +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from app.db.base import Base +from app.models.user import User +from app.models.store_app import StoreApp +from app.models.review import Review + + +def test_review_model_persists_tagged_rating(): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + + with Session(engine) as session: + user = User(flarum_user_id="123", username="momen", display_name="Momen") + app = StoreApp( + app_key="apm:amd64-apm:office:wps", + pkgname="wps", + origin="apm", + store_arch="amd64-apm", + category="office", + latest_seen_version="1.0.0", + ) + session.add_all([user, app]) + session.flush() + + review = Review( + user_id=user.id, + app_id=app.id, + rating=5, + content="Works well.", + version="1.0.0", + package_arch="amd64", + client_arch="amd64", + distro="deepin 25", + origin="apm", + category="office", + ) + session.add(review) + session.commit() + + stored = session.query(Review).one() + assert stored.rating == 5 + assert stored.version == "1.0.0" + assert stored.distro == "deepin 25" +``` + +- [ ] **Step 2: Run test and verify failure** + +Run: `pytest tests/test_models.py -v` + +Expected: FAIL with missing `app.db.base` or models. + +- [ ] **Step 3: Add database session and base** + +Create `app/db/session.py` with: + +```python +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.core.config import get_settings + +settings = get_settings() +engine = create_engine(settings.database_url, pool_pre_ping=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() +``` + +Create `app/db/base.py` with: + +```python +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass + + +from app.models.app_list import UserAppList, UserAppListItem # noqa: E402,F401 +from app.models.review import Review # noqa: E402,F401 +from app.models.store_app import StoreApp # noqa: E402,F401 +from app.models.user import User # noqa: E402,F401 +``` + +- [ ] **Step 4: Add ORM models** + +Create `app/models/user.py` with: + +```python +from datetime import datetime, timezone + +from sqlalchemy import DateTime, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + flarum_user_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + username: Mapped[str] = mapped_column(String(128), default="") + display_name: Mapped[str] = mapped_column(String(128), default="") + avatar_url: Mapped[str] = mapped_column(String(1024), default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) + + reviews = relationship("Review", back_populates="user") + app_lists = relationship("UserAppList", back_populates="user") +``` + +Create `app/models/store_app.py` with: + +```python +from datetime import datetime, timezone + +from sqlalchemy import DateTime, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class StoreApp(Base): + __tablename__ = "apps" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + app_key: Mapped[str] = mapped_column(String(512), unique=True, index=True) + pkgname: Mapped[str] = mapped_column(String(255), index=True) + origin: Mapped[str] = mapped_column(String(16), index=True) + store_arch: Mapped[str] = mapped_column(String(64), index=True) + category: Mapped[str] = mapped_column(String(128), index=True) + latest_seen_version: Mapped[str] = mapped_column(String(255), default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) + + reviews = relationship("Review", back_populates="app") +``` + +Create `app/models/review.py` with: + +```python +from datetime import datetime, timezone + +from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class Review(Base): + __tablename__ = "reviews" + __table_args__ = ( + CheckConstraint("rating >= 1 AND rating <= 5", name="ck_reviews_rating_range"), + UniqueConstraint( + "user_id", + "app_id", + "version", + "package_arch", + "client_arch", + "distro", + "origin", + "category", + name="uq_reviews_user_app_tags", + ), + Index("ix_reviews_app_filters", "app_id", "version", "package_arch", "client_arch", "distro", "origin", "category"), + ) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) + app_id: Mapped[int] = mapped_column(ForeignKey("apps.id"), index=True) + rating: Mapped[int] + content: Mapped[str] = mapped_column(Text) + version: Mapped[str] = mapped_column(String(255), default="unknown") + package_arch: Mapped[str] = mapped_column(String(64), default="unknown") + client_arch: Mapped[str] = mapped_column(String(64), default="unknown") + distro: Mapped[str] = mapped_column(String(255), default="unknown") + origin: Mapped[str] = mapped_column(String(16), default="") + category: Mapped[str] = mapped_column(String(128), default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) + + user = relationship("User", back_populates="reviews") + app = relationship("StoreApp", back_populates="reviews") +``` + +Create `app/models/app_list.py` with: + +```python +from datetime import datetime, timezone + +from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class UserAppList(Base): + __tablename__ = "user_app_lists" + __table_args__ = (UniqueConstraint("user_id", "snapshot_name", name="uq_user_app_lists_default"),) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) + snapshot_name: Mapped[str] = mapped_column(String(128), default="default") + client_arch: Mapped[str] = mapped_column(String(64), default="unknown") + distro: Mapped[str] = mapped_column(String(255), default="unknown") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) + + user = relationship("User", back_populates="app_lists") + items = relationship("UserAppListItem", back_populates="app_list", cascade="all, delete-orphan") + + +class UserAppListItem(Base): + __tablename__ = "user_app_list_items" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + list_id: Mapped[int] = mapped_column(ForeignKey("user_app_lists.id"), index=True) + pkgname: Mapped[str] = mapped_column(String(255), index=True) + origin: Mapped[str] = mapped_column(String(16), index=True) + category: Mapped[str] = mapped_column(String(128), index=True) + version: Mapped[str] = mapped_column(String(255), default="") + package_arch: Mapped[str] = mapped_column(String(64), default="unknown") + app_name: Mapped[str] = mapped_column(String(255), default="") + icon_url: Mapped[str] = mapped_column(String(1024), default="") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) + + app_list = relationship("UserAppList", back_populates="items") +``` + +- [ ] **Step 5: Run model test and verify pass** + +Run: `pytest tests/test_models.py -v` + +Expected: PASS. + +- [ ] **Step 6: Add Alembic migration** + +Create `alembic.ini` with: + +```ini +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = sqlite:///./spark-store-dev.sqlite3 + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +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 +``` + +Create `alembic/env.py` with: + +```python +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.core.config import get_settings +from app.db.base import Base + +config = context.config +config.set_main_option("sqlalchemy.url", get_settings().database_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True, compare_type=True) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata, compare_type=True) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() +``` + +Create `alembic/versions/0001_initial.py` with: + +```python +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0001_initial" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("flarum_user_id", sa.String(length=64), nullable=False), + sa.Column("username", sa.String(length=128), nullable=False), + sa.Column("display_name", sa.String(length=128), nullable=False), + sa.Column("avatar_url", sa.String(length=1024), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("flarum_user_id"), + ) + op.create_index("ix_users_flarum_user_id", "users", ["flarum_user_id"]) + + op.create_table( + "apps", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("app_key", sa.String(length=512), nullable=False), + sa.Column("pkgname", sa.String(length=255), nullable=False), + sa.Column("origin", sa.String(length=16), nullable=False), + sa.Column("store_arch", sa.String(length=64), nullable=False), + sa.Column("category", sa.String(length=128), nullable=False), + sa.Column("latest_seen_version", sa.String(length=255), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("app_key"), + ) + op.create_index("ix_apps_app_key", "apps", ["app_key"]) + op.create_index("ix_apps_pkgname", "apps", ["pkgname"]) + op.create_index("ix_apps_origin", "apps", ["origin"]) + op.create_index("ix_apps_store_arch", "apps", ["store_arch"]) + op.create_index("ix_apps_category", "apps", ["category"]) + + op.create_table( + "reviews", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("app_id", sa.Integer(), sa.ForeignKey("apps.id"), nullable=False), + sa.Column("rating", sa.Integer(), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("version", sa.String(length=255), nullable=False), + sa.Column("package_arch", sa.String(length=64), nullable=False), + sa.Column("client_arch", sa.String(length=64), nullable=False), + sa.Column("distro", sa.String(length=255), nullable=False), + sa.Column("origin", sa.String(length=16), nullable=False), + sa.Column("category", sa.String(length=128), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.CheckConstraint("rating >= 1 AND rating <= 5", name="ck_reviews_rating_range"), + sa.UniqueConstraint("user_id", "app_id", "version", "package_arch", "client_arch", "distro", "origin", "category", name="uq_reviews_user_app_tags"), + ) + op.create_index("ix_reviews_user_id", "reviews", ["user_id"]) + op.create_index("ix_reviews_app_id", "reviews", ["app_id"]) + op.create_index("ix_reviews_app_filters", "reviews", ["app_id", "version", "package_arch", "client_arch", "distro", "origin", "category"]) + + op.create_table( + "user_app_lists", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("snapshot_name", sa.String(length=128), nullable=False), + sa.Column("client_arch", sa.String(length=64), nullable=False), + sa.Column("distro", sa.String(length=255), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("user_id", "snapshot_name", name="uq_user_app_lists_default"), + ) + op.create_index("ix_user_app_lists_user_id", "user_app_lists", ["user_id"]) + + op.create_table( + "user_app_list_items", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("list_id", sa.Integer(), sa.ForeignKey("user_app_lists.id"), nullable=False), + sa.Column("pkgname", sa.String(length=255), nullable=False), + sa.Column("origin", sa.String(length=16), nullable=False), + sa.Column("category", sa.String(length=128), nullable=False), + sa.Column("version", sa.String(length=255), nullable=False), + sa.Column("package_arch", sa.String(length=64), nullable=False), + sa.Column("app_name", sa.String(length=255), nullable=False), + sa.Column("icon_url", sa.String(length=1024), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_user_app_list_items_list_id", "user_app_list_items", ["list_id"]) + op.create_index("ix_user_app_list_items_pkgname", "user_app_list_items", ["pkgname"]) + op.create_index("ix_user_app_list_items_origin", "user_app_list_items", ["origin"]) + op.create_index("ix_user_app_list_items_category", "user_app_list_items", ["category"]) + + +def downgrade() -> None: + op.drop_table("user_app_list_items") + op.drop_table("user_app_lists") + op.drop_table("reviews") + op.drop_table("apps") + op.drop_table("users") +``` + +Run: `alembic upgrade head` + +Expected: local development database receives revision `0001_initial`. + +- [ ] **Step 7: Commit database layer** + +Run: + +```bash +git add app/db app/models alembic.ini alembic tests/test_models.py +git commit -m "feat: add database schema" +``` + +Expected: commit succeeds. + +## Task 4: Implement Flarum Auth And JWT + +**Files:** +- Create: `app/core/security.py` +- Create: `app/services/flarum.py` +- Create: `app/schemas/auth.py` +- Create: `app/api/deps.py` +- Create: `app/api/routes/auth.py` +- Modify: `app/api/router.py` +- Test: `tests/conftest.py` +- Test: `tests/test_auth.py` + +- [ ] **Step 1: Write failing auth tests** + +Create `tests/conftest.py` with a SQLite test database, dependency override for `get_db`, and `TestClient(create_app())` fixture. + +Create `tests/test_auth.py` with: + +```python +from app.models.user import User + + +def test_auth_flarum_creates_user_and_returns_jwt(client, monkeypatch): + async def fake_fetch_profile(token: str, user_id: str): + assert token == "forum-token" + assert user_id == "123" + return { + "flarum_user_id": "123", + "username": "momen", + "display_name": "Momen", + "avatar_url": "https://bbs.spark-app.store/avatar.png", + } + + monkeypatch.setattr("app.api.routes.auth.fetch_flarum_profile", fake_fetch_profile) + + response = client.post("/auth/flarum", json={"flarum_user_id": "123", "flarum_token": "forum-token"}) + + assert response.status_code == 200 + data = response.json() + assert data["token_type"] == "bearer" + assert data["access_token"] + assert data["user"]["display_name"] == "Momen" + + +def test_me_requires_jwt(client): + response = client.get("/me") + assert response.status_code == 401 +``` + +- [ ] **Step 2: Run auth tests and verify failure** + +Run: `pytest tests/test_auth.py -v` + +Expected: FAIL with missing auth routes or schemas. + +- [ ] **Step 3: Add JWT helpers** + +Create `app/core/security.py` with: + +```python +from datetime import datetime, timedelta, timezone + +from jose import JWTError, jwt + +from app.core.config import get_settings + + +def create_access_token(subject: str) -> str: + settings = get_settings() + expires = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes) + payload = {"sub": subject, "exp": expires} + return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm) + + +def decode_access_token(token: str) -> str | None: + settings = get_settings() + try: + payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm]) + except JWTError: + return None + subject = payload.get("sub") + return subject if isinstance(subject, str) else None +``` + +- [ ] **Step 4: Add auth schemas and Flarum service** + +Create `app/schemas/auth.py` with: + +```python +from pydantic import BaseModel, Field + + +class FlarumAuthRequest(BaseModel): + flarum_user_id: str = Field(min_length=1, max_length=64) + flarum_token: str = Field(min_length=1) + + +class UserPublic(BaseModel): + id: int + flarum_user_id: str + username: str + display_name: str + avatar_url: str + + model_config = {"from_attributes": True} + + +class AuthResponse(BaseModel): + access_token: str + token_type: str = "bearer" + user: UserPublic +``` + +Create `app/services/flarum.py` with: + +```python +import httpx + +from app.core.config import get_settings + + +async def fetch_flarum_profile(token: str, user_id: str) -> dict[str, str]: + settings = get_settings() + url = f"{settings.flarum_base_url.rstrip('/')}/api/users/{user_id}" + async with httpx.AsyncClient(timeout=10) as client: + response = await client.get(url, headers={"Authorization": f"Token {token}"}) + + if response.status_code != 200: + raise ValueError("invalid flarum token") + + data = response.json()["data"] + attrs = data.get("attributes", {}) + return { + "flarum_user_id": str(data.get("id", user_id)), + "username": attrs.get("username") or "", + "display_name": attrs.get("displayName") or attrs.get("username") or "", + "avatar_url": attrs.get("avatarUrl") or "", + } +``` + +- [ ] **Step 5: Add auth dependency and routes** + +Create `app/api/deps.py` with: + +```python +from typing import Annotated + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.orm import Session + +from app.core.security import decode_access_token +from app.db.session import get_db +from app.models.user import User + +bearer = HTTPBearer(auto_error=False) + + +def get_current_user( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)], + db: Annotated[Session, Depends(get_db)], +) -> User: + if credentials is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing bearer token") + subject = decode_access_token(credentials.credentials) + if subject is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid bearer token") + user = db.get(User, int(subject)) if subject.isdigit() else None + if user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found") + return user +``` + +Create `app/api/routes/auth.py` with: + +```python +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user +from app.core.security import create_access_token +from app.db.session import get_db +from app.models.user import User +from app.schemas.auth import AuthResponse, FlarumAuthRequest, UserPublic +from app.services.flarum import fetch_flarum_profile + +router = APIRouter(tags=["auth"]) + + +@router.post("/auth/flarum", response_model=AuthResponse) +async def auth_flarum(payload: FlarumAuthRequest, db: Session = Depends(get_db)) -> AuthResponse: + try: + profile = await fetch_flarum_profile(payload.flarum_token, payload.flarum_user_id) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid flarum token") from exc + + user = db.scalar(select(User).where(User.flarum_user_id == profile["flarum_user_id"])) + if user is None: + user = User(**profile) + db.add(user) + else: + user.username = profile["username"] + user.display_name = profile["display_name"] + user.avatar_url = profile["avatar_url"] + db.commit() + db.refresh(user) + + return AuthResponse(access_token=create_access_token(str(user.id)), user=UserPublic.model_validate(user)) + + +@router.get("/me", response_model=UserPublic) +def me(user: User = Depends(get_current_user)) -> User: + return user +``` + +Modify `app/api/router.py` to include: + +```python +from fastapi import APIRouter + +from app.api.routes import auth + +api_router = APIRouter() +api_router.include_router(auth.router) +``` + +- [ ] **Step 6: Run auth tests and verify pass** + +Run: `pytest tests/test_auth.py -v` + +Expected: PASS. + +- [ ] **Step 7: Commit auth** + +Run: + +```bash +git add app tests/test_auth.py tests/conftest.py +git commit -m "feat: add Flarum auth" +``` + +Expected: commit succeeds. + +## Task 5: Implement Reviews And Rating Summary + +**Files:** +- Create: `app/schemas/review.py` +- Create: `app/api/routes/reviews.py` +- Modify: `app/api/router.py` +- Test: `tests/test_reviews.py` + +- [ ] **Step 1: Write failing review tests** + +Create `tests/test_reviews.py` with: + +```python +from app.core.security import create_access_token +from app.models.user import User + + +def seed_user(db_session): + user = User(flarum_user_id="123", username="momen", display_name="Momen", avatar_url="") + db_session.add(user) + db_session.commit() + db_session.refresh(user) + return user + + +def auth_headers(user: User) -> dict[str, str]: + return {"Authorization": f"Bearer {create_access_token(str(user.id))}"} + + +def test_create_review_and_rating_summary(client, db_session): + user = seed_user(db_session) + + response = client.post( + "/apps/apm:amd64-apm:office:wps/reviews", + headers=auth_headers(user), + json={ + "rating": 5, + "content": "Works well.", + "tags": { + "origin": "apm", + "category": "office", + "pkgname": "wps", + "version": "1.0.0", + "package_arch": "amd64", + "client_arch": "amd64", + "distro": "deepin 25", + }, + }, + ) + + assert response.status_code == 200 + assert response.json()["rating"] == 5 + + summary = client.get("/apps/apm:amd64-apm:office:wps/rating-summary") + assert summary.status_code == 200 + assert summary.json()["average_rating"] == 5.0 + assert summary.json()["review_count"] == 1 + assert summary.json()["star_counts"]["5"] == 1 + + +def test_reviews_filter_by_version(client, db_session): + user = seed_user(db_session) + headers = auth_headers(user) + + for version in ["1.0.0", "2.0.0"]: + response = client.post( + "/apps/apm:amd64-apm:office:wps/reviews", + headers=headers, + json={ + "rating": 4, + "content": f"Version {version}", + "tags": { + "origin": "apm", + "category": "office", + "pkgname": "wps", + "version": version, + "package_arch": "amd64", + "client_arch": "amd64", + "distro": "deepin 25", + }, + }, + ) + assert response.status_code == 200 + + response = client.get("/apps/apm:amd64-apm:office:wps/reviews", params={"version": "2.0.0"}) + + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["version"] == "2.0.0" +``` + +- [ ] **Step 2: Run review tests and verify failure** + +Run: `pytest tests/test_reviews.py -v` + +Expected: FAIL with `404 Not Found` for review endpoints. + +- [ ] **Step 3: Add review schemas** + +Create `app/schemas/review.py` with: + +```python +from datetime import datetime + +from pydantic import BaseModel, Field + + +class ReviewTags(BaseModel): + origin: str = Field(min_length=1, max_length=16) + category: str = Field(min_length=1, max_length=128) + pkgname: str = Field(min_length=1, max_length=255) + version: str = Field(default="unknown", max_length=255) + package_arch: str = Field(default="unknown", max_length=64) + client_arch: str = Field(default="unknown", max_length=64) + distro: str = Field(default="unknown", max_length=255) + + +class ReviewCreate(BaseModel): + rating: int = Field(ge=1, le=5) + content: str = Field(min_length=1, max_length=5000) + tags: ReviewTags + + +class ReviewPublic(BaseModel): + id: int + rating: int + content: str + version: str + package_arch: str + client_arch: str + distro: str + origin: str + category: str + created_at: datetime + updated_at: datetime + user_display_name: str + user_avatar_url: str + + +class RatingSummary(BaseModel): + average_rating: float + review_count: int + star_counts: dict[int, int] +``` + +- [ ] **Step 4: Add review routes** + +Create `app/api/routes/reviews.py` with: + +```python +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user +from app.db.session import get_db +from app.models.review import Review +from app.models.store_app import StoreApp +from app.models.user import User +from app.schemas.review import RatingSummary, ReviewCreate, ReviewPublic + +router = APIRouter(tags=["reviews"]) + + +def parse_app_key(app_key: str) -> dict[str, str]: + parts = app_key.split(":", 3) + if len(parts) != 4: + raise HTTPException(status_code=422, detail="invalid app key") + origin, store_arch, category, pkgname = parts + return {"origin": origin, "store_arch": store_arch, "category": category, "pkgname": pkgname} + + +def get_or_create_app(db: Session, app_key: str) -> StoreApp: + parsed = parse_app_key(app_key) + app = db.scalar(select(StoreApp).where(StoreApp.app_key == app_key)) + if app is not None: + return app + app = StoreApp(app_key=app_key, latest_seen_version="", **parsed) + db.add(app) + db.flush() + return app + + +def to_public(review: Review) -> ReviewPublic: + return ReviewPublic( + id=review.id, + rating=review.rating, + content=review.content, + version=review.version, + package_arch=review.package_arch, + client_arch=review.client_arch, + distro=review.distro, + origin=review.origin, + category=review.category, + created_at=review.created_at, + updated_at=review.updated_at, + user_display_name=review.user.display_name, + user_avatar_url=review.user.avatar_url, + ) + + +@router.post("/apps/{app_key}/reviews", response_model=ReviewPublic) +def upsert_review( + app_key: str, + payload: ReviewCreate, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> ReviewPublic: + app = get_or_create_app(db, app_key) + tags = payload.tags + review = db.scalar( + select(Review).where( + Review.user_id == user.id, + Review.app_id == app.id, + Review.version == tags.version, + Review.package_arch == tags.package_arch, + Review.client_arch == tags.client_arch, + Review.distro == tags.distro, + Review.origin == tags.origin, + Review.category == tags.category, + ) + ) + if review is None: + review = Review(user_id=user.id, app_id=app.id) + db.add(review) + review.rating = payload.rating + review.content = payload.content.strip() + review.version = tags.version or "unknown" + review.package_arch = tags.package_arch or "unknown" + review.client_arch = tags.client_arch or "unknown" + review.distro = tags.distro or "unknown" + review.origin = tags.origin + review.category = tags.category + app.latest_seen_version = review.version + db.commit() + db.refresh(review) + return to_public(review) + + +@router.get("/apps/{app_key}/reviews", response_model=list[ReviewPublic]) +def list_reviews( + app_key: str, + version: str | None = None, + package_arch: str | None = None, + client_arch: str | None = None, + distro: str | None = None, + origin: str | None = None, + category: str | None = None, + rating: int | None = Query(default=None, ge=1, le=5), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + db: Session = Depends(get_db), +) -> list[ReviewPublic]: + app = db.scalar(select(StoreApp).where(StoreApp.app_key == app_key)) + if app is None: + return [] + stmt = select(Review).where(Review.app_id == app.id).order_by(Review.updated_at.desc()) + filters = { + Review.version: version, + Review.package_arch: package_arch, + Review.client_arch: client_arch, + Review.distro: distro, + Review.origin: origin, + Review.category: category, + Review.rating: rating, + } + for column, value in filters.items(): + if value is not None: + stmt = stmt.where(column == value) + reviews = db.scalars(stmt.offset((page - 1) * page_size).limit(page_size)).all() + return [to_public(review) for review in reviews] + + +@router.get("/apps/{app_key}/rating-summary", response_model=RatingSummary) +def rating_summary(app_key: str, db: Session = Depends(get_db)) -> RatingSummary: + app = db.scalar(select(StoreApp).where(StoreApp.app_key == app_key)) + if app is None: + return RatingSummary(average_rating=0.0, review_count=0, star_counts={star: 0 for star in range(1, 6)}) + rows = db.execute(select(Review.rating, func.count(Review.id)).where(Review.app_id == app.id).group_by(Review.rating)).all() + star_counts = {star: 0 for star in range(1, 6)} + total = 0 + count = 0 + for rating, rating_count in rows: + star_counts[int(rating)] = int(rating_count) + total += int(rating) * int(rating_count) + count += int(rating_count) + average = round(total / count, 2) if count else 0.0 + return RatingSummary(average_rating=average, review_count=count, star_counts=star_counts) +``` + +- [ ] **Step 5: Include routes** + +Modify `app/api/router.py` to include: + +```python +from app.api.routes import auth, reviews + +api_router = APIRouter() +api_router.include_router(auth.router) +api_router.include_router(reviews.router) +``` + +- [ ] **Step 6: Run review tests and verify pass** + +Run: `pytest tests/test_reviews.py -v` + +Expected: PASS. + +- [ ] **Step 7: Commit reviews** + +Run: + +```bash +git add app tests/test_reviews.py +git commit -m "feat: add app reviews and ratings" +``` + +Expected: commit succeeds. + +## Task 6: Implement App-List Sync + +**Files:** +- Create: `app/schemas/app_list.py` +- Create: `app/api/routes/app_lists.py` +- Modify: `app/api/router.py` +- Test: `tests/test_app_lists.py` + +- [ ] **Step 1: Write failing app-list tests** + +Create `tests/test_app_lists.py` with: + +```python +from app.core.security import create_access_token +from app.models.user import User + + +def seed_user(db_session): + user = User(flarum_user_id="123", username="momen", display_name="Momen", avatar_url="") + db_session.add(user) + db_session.commit() + db_session.refresh(user) + return user + + +def auth_headers(user: User) -> dict[str, str]: + return {"Authorization": f"Bearer {create_access_token(str(user.id))}"} + + +def test_put_and_get_default_app_list(client, db_session): + user = seed_user(db_session) + response = client.put( + "/me/app-list", + headers=auth_headers(user), + json={ + "client_arch": "amd64", + "distro": "deepin 25", + "items": [ + { + "pkgname": "spark-notes", + "origin": "spark", + "category": "office", + "version": "1.0.0", + "package_arch": "amd64", + "app_name": "Spark Notes", + "icon_url": "https://example.com/icon.png", + }, + { + "pkgname": "wps", + "origin": "apm", + "category": "office", + "version": "2.0.0", + "package_arch": "amd64", + "app_name": "WPS", + "icon_url": "", + }, + ], + }, + ) + + assert response.status_code == 200 + assert len(response.json()["items"]) == 2 + + fetched = client.get("/me/app-list", headers=auth_headers(user)) + + assert fetched.status_code == 200 + assert fetched.json()["client_arch"] == "amd64" + assert [item["pkgname"] for item in fetched.json()["items"]] == ["spark-notes", "wps"] + + +def test_put_default_app_list_replaces_previous_items(client, db_session): + user = seed_user(db_session) + headers = auth_headers(user) + first_payload = { + "client_arch": "amd64", + "distro": "deepin 25", + "items": [ + {"pkgname": "one", "origin": "spark", "category": "tools", "version": "1", "package_arch": "amd64", "app_name": "One", "icon_url": ""}, + {"pkgname": "two", "origin": "apm", "category": "tools", "version": "1", "package_arch": "amd64", "app_name": "Two", "icon_url": ""}, + ], + } + second_payload = { + "client_arch": "amd64", + "distro": "deepin 25", + "items": [ + {"pkgname": "three", "origin": "spark", "category": "tools", "version": "1", "package_arch": "amd64", "app_name": "Three", "icon_url": ""}, + ], + } + + assert client.put("/me/app-list", headers=headers, json=first_payload).status_code == 200 + assert client.put("/me/app-list", headers=headers, json=second_payload).status_code == 200 + fetched = client.get("/me/app-list", headers=headers) + + assert [item["pkgname"] for item in fetched.json()["items"]] == ["three"] +``` + +- [ ] **Step 2: Run app-list tests and verify failure** + +Run: `pytest tests/test_app_lists.py -v` + +Expected: FAIL with `404 Not Found` for `/me/app-list`. + +- [ ] **Step 3: Add app-list schemas** + +Create `app/schemas/app_list.py` with: + +```python +from datetime import datetime + +from pydantic import BaseModel, Field + + +class AppListItemIn(BaseModel): + pkgname: str = Field(min_length=1, max_length=255) + origin: str = Field(min_length=1, max_length=16) + category: str = Field(min_length=1, max_length=128) + version: str = Field(default="", max_length=255) + package_arch: str = Field(default="unknown", max_length=64) + app_name: str = Field(default="", max_length=255) + icon_url: str = Field(default="", max_length=1024) + + +class AppListPut(BaseModel): + client_arch: str = Field(default="unknown", max_length=64) + distro: str = Field(default="unknown", max_length=255) + items: list[AppListItemIn] + + +class AppListItemOut(AppListItemIn): + id: int + + model_config = {"from_attributes": True} + + +class AppListOut(BaseModel): + snapshot_name: str + client_arch: str + distro: str + updated_at: datetime + items: list[AppListItemOut] +``` + +- [ ] **Step 4: Add app-list routes** + +Create `app/api/routes/app_lists.py` with: + +```python +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user +from app.db.session import get_db +from app.models.app_list import UserAppList, UserAppListItem +from app.models.user import User +from app.schemas.app_list import AppListOut, AppListPut + +router = APIRouter(tags=["app-lists"]) + + +def to_out(app_list: UserAppList) -> AppListOut: + return AppListOut( + snapshot_name=app_list.snapshot_name, + client_arch=app_list.client_arch, + distro=app_list.distro, + updated_at=app_list.updated_at, + items=list(app_list.items), + ) + + +@router.get("/me/app-list", response_model=AppListOut | None) +def get_app_list( + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> AppListOut | None: + app_list = db.scalar( + select(UserAppList).where( + UserAppList.user_id == user.id, + UserAppList.snapshot_name == "default", + ) + ) + return to_out(app_list) if app_list else None + + +@router.put("/me/app-list", response_model=AppListOut) +def put_app_list( + payload: AppListPut, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> AppListOut: + app_list = db.scalar( + select(UserAppList).where( + UserAppList.user_id == user.id, + UserAppList.snapshot_name == "default", + ) + ) + if app_list is None: + app_list = UserAppList(user_id=user.id, snapshot_name="default") + db.add(app_list) + db.flush() + + app_list.client_arch = payload.client_arch + app_list.distro = payload.distro + app_list.items.clear() + for item in payload.items: + app_list.items.append(UserAppListItem(**item.model_dump())) + db.commit() + db.refresh(app_list) + return to_out(app_list) +``` + +- [ ] **Step 5: Include routes** + +Modify `app/api/router.py` to include: + +```python +from app.api.routes import app_lists, auth, reviews + +api_router = APIRouter() +api_router.include_router(auth.router) +api_router.include_router(reviews.router) +api_router.include_router(app_lists.router) +``` + +- [ ] **Step 6: Run app-list tests and verify pass** + +Run: `pytest tests/test_app_lists.py -v` + +Expected: PASS. + +- [ ] **Step 7: Commit app-list sync** + +Run: + +```bash +git add app tests/test_app_lists.py +git commit -m "feat: add app list sync api" +``` + +Expected: commit succeeds. + +## Task 7: Final Backend Verification And Push + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Document run commands** + +Add this section to `README.md`: + +```markdown +## Run + +```bash +uvicorn app.main:app --reload +``` + +## Verify + +```bash +pytest +alembic upgrade head +``` + +## API Groups + +- `POST /auth/flarum` +- `GET /me` +- `GET /apps/{app_key}/rating-summary` +- `GET /apps/{app_key}/reviews` +- `POST /apps/{app_key}/reviews` +- `GET /me/app-list` +- `PUT /me/app-list` +``` + +- [ ] **Step 2: Run full backend tests** + +Run: `pytest -v` + +Expected: all tests PASS. + +- [ ] **Step 3: Verify migration command** + +Run: `alembic upgrade head` + +Expected: command exits 0. + +- [ ] **Step 4: Commit docs** + +Run: + +```bash +git add README.md +git commit -m "docs: add backend run instructions" +``` + +Expected: commit succeeds if README changed. + +- [ ] **Step 5: Push backend repository** + +Run: `git push -u origin master` + +Expected: push succeeds if credentials and remote permissions are available. If authentication fails, report the exact Git error and leave the local repository intact. + +## Self-Review Checklist + +Spec coverage: + +- Flarum token validation: Task 4. +- Backend JWT: Task 4. +- Review/rating storage and filtering foundation: Task 5. +- Default app-list sync: Task 6. +- MySQL schema and migrations: Task 3. +- New Git repository and remote: Task 1. + +Placeholder scan: + +- The plan has no deferred implementation sections and no placeholder tasks. + +Type consistency: + +- User id is local integer `users.id` inside JWT `sub`. +- Flarum user id remains string `flarum_user_id`. +- App identity uses string `app_key` in all review routes. diff --git a/docs/superpowers/plans/2026-05-18-spark-client-account-reviews-sync.md b/docs/superpowers/plans/2026-05-18-spark-client-account-reviews-sync.md new file mode 100644 index 00000000..763c4252 --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-spark-client-account-reviews-sync.md @@ -0,0 +1,1481 @@ +# Spark Client Account Reviews Sync Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Integrate Spark Store's Electron/Vue client with the new backend for Flarum login, profile display, app reviews/ratings, immutable review tags, and user app-list sync/restore. + +**Architecture:** Keep backend API access in focused TypeScript modules, keep global auth state in one Vue module, and add small child components instead of expanding the already-large `AppDetailModal.vue` and `InstalledAppsModal.vue`. Local system facts such as distro are exposed through Electron IPC and combined with app metadata to create immutable review tags. + +**Tech Stack:** Electron 40, Vue 3 Composition API, TypeScript strict mode, Axios, Vitest, Testing Library Vue, existing IPC facade and install queue. + +--- + +## File Structure + +Existing files to modify: + +- Modify: `.gitignore` - ignore `.superpowers/` visual companion artifacts. +- Modify: `electron/main/index.ts` - add `get-system-info` IPC handler that safely reads `/etc/os-release`. +- Modify: `src/vite-env.d.ts` - add auth/backend/system-info types. +- Modify: `src/global/typedefinition.ts` - add user, review, rating, app-list sync types. +- Modify: `src/global/storeConfig.ts` - add `SPARK_BACKEND_BASE_URL` config. +- Modify: `src/components/AppHeader.vue` - show login/profile action. +- Modify: `src/components/AppDetailModal.vue` - mount `ReviewsPanel` with the active display app. +- Modify: `src/components/InstalledAppsModal.vue` - add sync/restore action buttons and restore modal entry events. +- Modify: `src/App.vue` - coordinate auth modal, review props, app-list sync/restore flow, and system info loading. +- Modify: `src/__tests__/setup.ts` - extend window mocks. + +New files to create: + +- Create: `src/global/authState.ts` - auth state, persistence, login/logout helpers. +- Create: `src/modules/backendApi.ts` - Axios client and backend request helpers. +- Create: `src/modules/reviewTags.ts` - immutable review tag construction and app key creation. +- Create: `src/modules/appListSync.ts` - store-recognized installed-app filtering and restore-plan helpers. +- Create: `src/components/LoginModal.vue` - Flarum login UI. +- Create: `src/components/ReviewsPanel.vue` - rating summary, filters, review list, composer. +- Create: `src/components/AppListRestoreModal.vue` - cloud app-list restore selector. +- Create: `src/__tests__/unit/reviewTags.test.ts` - immutable tag tests. +- Create: `src/__tests__/unit/appListSync.test.ts` - sync filtering tests. +- Create: `src/__tests__/unit/LoginModal.test.ts` - login UI tests. +- Create: `src/__tests__/unit/ReviewsPanel.test.ts` - review panel state tests. +- Create: `src/__tests__/unit/AppListRestoreModal.test.ts` - restore modal tests. + +## Task 1: Add Backend Config And Shared Types + +**Files:** +- Modify: `.gitignore` +- Modify: `src/global/storeConfig.ts` +- Modify: `src/global/typedefinition.ts` +- Modify: `src/vite-env.d.ts` +- Test: `src/__tests__/setup.ts` + +- [ ] **Step 1: Write type/import smoke test** + +Create `src/__tests__/unit/accountTypes.test.ts` with: + +```typescript +import { describe, expect, it } from "vitest"; + +import { SPARK_BACKEND_BASE_URL } from "@/global/storeConfig"; +import type { ReviewTags, SparkUser } from "@/global/typedefinition"; + +describe("account backend types", () => { + it("exports backend url and account types", () => { + const user: SparkUser = { + id: 1, + flarumUserId: "123", + username: "momen", + displayName: "Momen", + avatarUrl: "https://bbs.spark-app.store/avatar.png", + }; + const tags: ReviewTags = { + origin: "apm", + category: "office", + pkgname: "wps", + version: "1.0.0", + packageArch: "amd64", + clientArch: "amd64", + distro: "deepin 25", + }; + + expect(typeof SPARK_BACKEND_BASE_URL).toBe("string"); + expect(user.displayName).toBe("Momen"); + expect(tags.packageArch).toBe("amd64"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `npm run test -- src/__tests__/unit/accountTypes.test.ts` + +Expected: FAIL because `SPARK_BACKEND_BASE_URL`, `SparkUser`, and `ReviewTags` do not exist. + +- [ ] **Step 3: Ignore visual companion artifacts** + +Modify `.gitignore` by adding: + +```gitignore +.superpowers/ +``` + +- [ ] **Step 4: Add backend config** + +Modify `src/global/storeConfig.ts` by adding after `APM_STORE_STATS_BASE_URL`: + +```typescript +export const SPARK_BACKEND_BASE_URL: string = + import.meta.env.VITE_SPARK_BACKEND_BASE_URL || ""; +``` + +- [ ] **Step 5: Add shared account types** + +Append to `src/global/typedefinition.ts`: + +```typescript +export interface SparkUser { + id: number; + flarumUserId: string; + username: string; + displayName: string; + avatarUrl: string; +} + +export interface AuthSession { + accessToken: string; + tokenType: "bearer"; + user: SparkUser; +} + +export interface ReviewTags { + origin: "spark" | "apm"; + category: string; + pkgname: string; + version: string; + packageArch: string; + clientArch: string; + distro: string; +} + +export interface RatingSummary { + averageRating: number; + reviewCount: number; + starCounts: Record; +} + +export interface AppReview { + id: number; + rating: number; + content: string; + version: string; + packageArch: string; + clientArch: string; + distro: string; + origin: "spark" | "apm"; + category: string; + createdAt: string; + updatedAt: string; + userDisplayName: string; + userAvatarUrl: string; +} + +export interface SyncedAppListItem { + id?: number; + pkgname: string; + origin: "spark" | "apm"; + category: string; + version: string; + packageArch: string; + appName: string; + iconUrl: string; +} + +export interface SyncedAppList { + snapshotName: string; + clientArch: string; + distro: string; + updatedAt: string; + items: SyncedAppListItem[]; +} + +export interface SystemInfo { + distro: string; +} +``` + +- [ ] **Step 6: Extend environment declarations** + +Modify `src/vite-env.d.ts` by adding to `ImportMetaEnv`: + +```typescript +interface ImportMetaEnv { + readonly VITE_SPARK_BACKEND_BASE_URL?: string; +} +``` + +- [ ] **Step 7: Extend test setup mocks** + +Modify `src/__tests__/setup.ts` so `window.apm_store.arch` is `amd64`, not `amd64-store`, and `ipcRenderer.invoke` can be overridden per test: + +```typescript +Object.defineProperty(window, "apm_store", { + value: { + arch: "amd64", + }, + writable: true, +}); +``` + +- [ ] **Step 8: Run test to verify pass** + +Run: `npm run test -- src/__tests__/unit/accountTypes.test.ts` + +Expected: PASS. + +- [ ] **Step 9: Commit shared config/types** + +Run: + +```bash +git add .gitignore src/global/storeConfig.ts src/global/typedefinition.ts src/vite-env.d.ts src/__tests__/setup.ts src/__tests__/unit/accountTypes.test.ts +git commit -m "feat(account): add backend config and types" +``` + +Expected: commit succeeds if the user requested commits for implementation execution. + +## Task 2: Add Backend API Client And Auth State + +**Files:** +- Create: `src/modules/backendApi.ts` +- Create: `src/global/authState.ts` +- Test: `src/__tests__/unit/authState.test.ts` + +- [ ] **Step 1: Write failing auth state test** + +Create `src/__tests__/unit/authState.test.ts` with: + +```typescript +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("authState", () => { + beforeEach(() => { + vi.resetModules(); + localStorage.clear(); + }); + + it("persists and clears a backend session", async () => { + const { authSession, setAuthSession, logout } = await import("@/global/authState"); + + setAuthSession({ + accessToken: "jwt", + tokenType: "bearer", + user: { + id: 1, + flarumUserId: "123", + username: "momen", + displayName: "Momen", + avatarUrl: "https://bbs.spark-app.store/avatar.png", + }, + }); + + expect(authSession.value?.accessToken).toBe("jwt"); + expect(JSON.parse(localStorage.getItem("spark-store-auth") || "{}").accessToken).toBe("jwt"); + + logout(); + + expect(authSession.value).toBeNull(); + expect(localStorage.getItem("spark-store-auth")).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `npm run test -- src/__tests__/unit/authState.test.ts` + +Expected: FAIL because `authState` module does not exist. + +- [ ] **Step 3: Add backend API client** + +Create `src/modules/backendApi.ts` with: + +```typescript +import axios from "axios"; + +import { SPARK_BACKEND_BASE_URL } from "@/global/storeConfig"; +import type { + AppReview, + AuthSession, + RatingSummary, + ReviewTags, + SyncedAppList, + SyncedAppListItem, +} from "@/global/typedefinition"; + +const backend = axios.create({ + baseURL: SPARK_BACKEND_BASE_URL, + timeout: 10000, +}); + +export const setBackendToken = (token: string | null) => { + if (token) { + backend.defaults.headers.common.Authorization = `Bearer ${token}`; + } else { + delete backend.defaults.headers.common.Authorization; + } +}; + +const toCamelReview = (raw: Record): AppReview => ({ + id: Number(raw.id), + rating: Number(raw.rating), + content: String(raw.content || ""), + version: String(raw.version || "unknown"), + packageArch: String(raw.package_arch || "unknown"), + clientArch: String(raw.client_arch || "unknown"), + distro: String(raw.distro || "unknown"), + origin: raw.origin === "spark" ? "spark" : "apm", + category: String(raw.category || ""), + createdAt: String(raw.created_at || ""), + updatedAt: String(raw.updated_at || ""), + userDisplayName: String(raw.user_display_name || ""), + userAvatarUrl: String(raw.user_avatar_url || ""), +}); + +export const exchangeFlarumToken = async (payload: { + flarumUserId: string; + flarumToken: string; +}): Promise => { + const response = await backend.post("/auth/flarum", { + flarum_user_id: payload.flarumUserId, + flarum_token: payload.flarumToken, + }); + const data = response.data; + return { + accessToken: data.access_token, + tokenType: data.token_type, + user: { + id: data.user.id, + flarumUserId: data.user.flarum_user_id, + username: data.user.username, + displayName: data.user.display_name, + avatarUrl: data.user.avatar_url, + }, + }; +}; + +export const fetchRatingSummary = async (appKey: string): Promise => { + const response = await backend.get(`/apps/${encodeURIComponent(appKey)}/rating-summary`); + return { + averageRating: Number(response.data.average_rating || 0), + reviewCount: Number(response.data.review_count || 0), + starCounts: response.data.star_counts || {}, + }; +}; + +export const fetchReviews = async (appKey: string, params: Record): Promise => { + const response = await backend.get(`/apps/${encodeURIComponent(appKey)}/reviews`, { params }); + return (response.data || []).map((item: Record) => toCamelReview(item)); +}; + +export const submitReview = async (appKey: string, payload: { rating: number; content: string; tags: ReviewTags }): Promise => { + const response = await backend.post(`/apps/${encodeURIComponent(appKey)}/reviews`, { + rating: payload.rating, + content: payload.content, + tags: { + origin: payload.tags.origin, + category: payload.tags.category, + pkgname: payload.tags.pkgname, + version: payload.tags.version, + package_arch: payload.tags.packageArch, + client_arch: payload.tags.clientArch, + distro: payload.tags.distro, + }, + }); + return toCamelReview(response.data); +}; + +export const fetchSyncedAppList = async (): Promise => { + const response = await backend.get("/me/app-list"); + if (!response.data) return null; + return { + snapshotName: response.data.snapshot_name, + clientArch: response.data.client_arch, + distro: response.data.distro, + updatedAt: response.data.updated_at, + items: (response.data.items || []).map((item: Record) => ({ + id: Number(item.id), + pkgname: String(item.pkgname || ""), + origin: item.origin === "spark" ? "spark" : "apm", + category: String(item.category || ""), + version: String(item.version || ""), + packageArch: String(item.package_arch || "unknown"), + appName: String(item.app_name || ""), + iconUrl: String(item.icon_url || ""), + })), + }; +}; + +export const uploadSyncedAppList = async (payload: { + clientArch: string; + distro: string; + items: SyncedAppListItem[]; +}): Promise => { + const response = await backend.put("/me/app-list", { + client_arch: payload.clientArch, + distro: payload.distro, + items: payload.items.map((item) => ({ + pkgname: item.pkgname, + origin: item.origin, + category: item.category, + version: item.version, + package_arch: item.packageArch, + app_name: item.appName, + icon_url: item.iconUrl, + })), + }); + return response.data; +}; +``` + +- [ ] **Step 4: Add auth state** + +Create `src/global/authState.ts` with: + +```typescript +import { computed, ref } from "vue"; + +import { setBackendToken } from "@/modules/backendApi"; +import type { AuthSession } from "@/global/typedefinition"; + +const STORAGE_KEY = "spark-store-auth"; + +const readStoredSession = (): AuthSession | null => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as AuthSession; + if (!parsed.accessToken || !parsed.user) return null; + return parsed; + } catch { + return null; + } +}; + +export const authSession = ref(readStoredSession()); +export const currentUser = computed(() => authSession.value?.user ?? null); +export const isLoggedIn = computed(() => Boolean(authSession.value?.accessToken)); + +setBackendToken(authSession.value?.accessToken ?? null); + +export const setAuthSession = (session: AuthSession) => { + authSession.value = session; + localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); + setBackendToken(session.accessToken); +}; + +export const logout = () => { + authSession.value = null; + localStorage.removeItem(STORAGE_KEY); + setBackendToken(null); +}; +``` + +- [ ] **Step 5: Run auth state test and verify pass** + +Run: `npm run test -- src/__tests__/unit/authState.test.ts` + +Expected: PASS. + +- [ ] **Step 6: Commit API/auth state** + +Run: + +```bash +git add src/modules/backendApi.ts src/global/authState.ts src/__tests__/unit/authState.test.ts +git commit -m "feat(account): add backend api and auth state" +``` + +Expected: commit succeeds if commits are requested for implementation execution. + +## Task 3: Add Flarum Login Modal And Header Account UI + +**Files:** +- Create: `src/components/LoginModal.vue` +- Modify: `src/components/AppHeader.vue` +- Modify: `src/App.vue` +- Test: `src/__tests__/unit/LoginModal.test.ts` + +- [ ] **Step 1: Write failing LoginModal test** + +Create `src/__tests__/unit/LoginModal.test.ts` with: + +```typescript +import { fireEvent, render, screen } from "@testing-library/vue"; +import { describe, expect, it, vi } from "vitest"; + +import LoginModal from "@/components/LoginModal.vue"; + +describe("LoginModal", () => { + it("emits login credentials", async () => { + const rendered = render(LoginModal, { + props: { show: true, loading: false, error: "" }, + }); + + await fireEvent.update(screen.getByLabelText("论坛账号"), "momen"); + await fireEvent.update(screen.getByLabelText("论坛密码"), "secret"); + await fireEvent.click(screen.getByRole("button", { name: "登录" })); + + expect(rendered.emitted("login")?.[0]?.[0]).toEqual({ identification: "momen", password: "secret" }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `npm run test -- src/__tests__/unit/LoginModal.test.ts` + +Expected: FAIL because `LoginModal.vue` does not exist. + +- [ ] **Step 3: Create LoginModal component** + +Create `src/components/LoginModal.vue` with: + +```vue + + + +``` + +- [ ] **Step 4: Run LoginModal test and verify pass** + +Run: `npm run test -- src/__tests__/unit/LoginModal.test.ts` + +Expected: PASS. + +- [ ] **Step 5: Add account UI props to header** + +Modify `src/components/AppHeader.vue` props and emits: + +```typescript +import type { SparkUser } from "@/global/typedefinition"; + +const props = defineProps<{ + searchQuery: string; + activeTab: string; + appsCount: number; + currentUser: SparkUser | null; +}>(); + +const emit = defineEmits<{ + (e: "update-search", query: string): void; + (e: "search-focus"): void; + (e: "open-install-settings"): void; + (e: "open-about"): void; + (e: "toggle-sidebar"): void; + (e: "login"): void; + (e: "logout"): void; +}>(); +``` + +Add a button after the About button: + +```vue + + +``` + +- [ ] **Step 6: Wire login modal in App.vue** + +Modify `src/App.vue` to import `LoginModal`, `currentUser`, `setAuthSession`, `logout`, and `exchangeFlarumToken`. Add state: + +```typescript +const showLoginModal = ref(false); +const loginLoading = ref(false); +const loginError = ref(""); +``` + +Pass `:current-user="currentUser"` to `AppHeader`, add `@login="showLoginModal = true"` and `@logout="logout"`, and mount: + +```vue + +``` + +Add handler: + +```typescript +const handleFlarumLogin = async (payload: { identification: string; password: string }) => { + loginLoading.value = true; + loginError.value = ""; + try { + const response = await axios.post("https://bbs.spark-app.store/api/token", { + identification: payload.identification, + password: payload.password, + }); + const session = await exchangeFlarumToken({ + flarumUserId: String(response.data.userId), + flarumToken: String(response.data.token), + }); + setAuthSession(session); + showLoginModal.value = false; + } catch (error) { + loginError.value = error instanceof Error ? error.message : "登录失败"; + } finally { + loginLoading.value = false; + } +}; +``` + +- [ ] **Step 7: Run targeted tests** + +Run: `npm run test -- src/__tests__/unit/LoginModal.test.ts src/__tests__/unit/authState.test.ts` + +Expected: PASS. + +- [ ] **Step 8: Commit login UI** + +Run: + +```bash +git add src/components/LoginModal.vue src/components/AppHeader.vue src/App.vue src/__tests__/unit/LoginModal.test.ts +git commit -m "feat(account): add Flarum login UI" +``` + +Expected: commit succeeds if commits are requested for implementation execution. + +## Task 4: Add System Info IPC And Immutable Review Tags + +**Files:** +- Modify: `electron/main/index.ts` +- Create: `src/modules/reviewTags.ts` +- Test: `src/__tests__/unit/reviewTags.test.ts` + +- [ ] **Step 1: Write failing review tag tests** + +Create `src/__tests__/unit/reviewTags.test.ts` with: + +```typescript +import { describe, expect, it } from "vitest"; + +import { buildAppKey, buildReviewTags, parsePackageArch } from "@/modules/reviewTags"; +import type { App } from "@/global/typedefinition"; + +const app: App = { + name: "WPS", + pkgname: "wps", + version: "1.0.0", + filename: "wps_1.0.0_amd64.deb", + torrent_address: "", + author: "", + contributor: "", + website: "", + update: "", + size: "", + more: "", + tags: "", + img_urls: [], + icons: "", + category: "office", + origin: "apm", + currentStatus: "installed", +}; + +describe("reviewTags", () => { + it("builds stable app keys", () => { + expect(buildAppKey(app, "amd64")).toBe("apm:amd64-apm:office:wps"); + }); + + it("parses package architecture from deb filename", () => { + expect(parsePackageArch("wps_1.0.0_amd64.deb")).toBe("amd64"); + }); + + it("builds immutable review tags", () => { + expect(buildReviewTags(app, { clientArch: "amd64", distro: "deepin 25" })).toEqual({ + origin: "apm", + category: "office", + pkgname: "wps", + version: "1.0.0", + packageArch: "amd64", + clientArch: "amd64", + distro: "deepin 25", + }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `npm run test -- src/__tests__/unit/reviewTags.test.ts` + +Expected: FAIL because `reviewTags` module does not exist. + +- [ ] **Step 3: Add review tag module** + +Create `src/modules/reviewTags.ts` with: + +```typescript +import type { App, ReviewTags } from "@/global/typedefinition"; + +export const parsePackageArch = (filename: string | undefined): string => { + if (!filename) return "unknown"; + const match = filename.match(/_([^_]+)\.(?:deb|rpm|appimage|tar\.gz)$/i); + return match?.[1] || "unknown"; +}; + +export const buildStoreArch = (origin: "spark" | "apm", clientArch: string): string => { + return origin === "spark" ? `${clientArch}-store` : `${clientArch}-apm`; +}; + +export const buildAppKey = (app: App, clientArch: string): string => { + return `${app.origin}:${buildStoreArch(app.origin, clientArch)}:${app.category}:${app.pkgname}`; +}; + +export const buildReviewTags = ( + app: App, + system: { clientArch: string; distro: string }, +): ReviewTags => ({ + origin: app.origin, + category: app.category || "unknown", + pkgname: app.pkgname, + version: app.version || "unknown", + packageArch: app.arch || parsePackageArch(app.filename), + clientArch: system.clientArch || "unknown", + distro: system.distro || "unknown", +}); +``` + +- [ ] **Step 4: Add system info IPC** + +Modify `electron/main/index.ts` by adding near `get-app-version`: + +```typescript +const getSystemInfo = (): { distro: string } => { + try { + const raw = fs.readFileSync("/etc/os-release", "utf8"); + const values = Object.fromEntries( + raw + .split("\n") + .filter((line) => line.includes("=")) + .map((line) => { + const [key, ...rest] = line.split("="); + return [key, rest.join("=").replace(/^"|"$/g, "")]; + }), + ); + const name = values.PRETTY_NAME || values.NAME || values.ID || "unknown"; + return { distro: name }; + } catch { + return { distro: "unknown" }; + } +}; + +ipcMain.handle("get-system-info", (): { distro: string } => getSystemInfo()); +``` + +- [ ] **Step 5: Run review tag tests and verify pass** + +Run: `npm run test -- src/__tests__/unit/reviewTags.test.ts` + +Expected: PASS. + +- [ ] **Step 6: Commit tags/system info** + +Run: + +```bash +git add electron/main/index.ts src/modules/reviewTags.ts src/__tests__/unit/reviewTags.test.ts +git commit -m "feat(reviews): add immutable review tags" +``` + +Expected: commit succeeds if commits are requested for implementation execution. + +## Task 5: Add ReviewsPanel To App Detail Modal + +**Files:** +- Create: `src/components/ReviewsPanel.vue` +- Modify: `src/components/AppDetailModal.vue` +- Modify: `src/App.vue` +- Test: `src/__tests__/unit/ReviewsPanel.test.ts` + +- [ ] **Step 1: Write failing ReviewsPanel test** + +Create `src/__tests__/unit/ReviewsPanel.test.ts` with: + +```typescript +import { render, screen } from "@testing-library/vue"; +import { describe, expect, it } from "vitest"; + +import ReviewsPanel from "@/components/ReviewsPanel.vue"; +import type { ReviewTags } from "@/global/typedefinition"; + +const tags: ReviewTags = { + origin: "apm", + category: "office", + pkgname: "wps", + version: "1.0.0", + packageArch: "amd64", + clientArch: "amd64", + distro: "deepin 25", +}; + +describe("ReviewsPanel", () => { + it("shows login prompt for anonymous users and read-only tags", () => { + render(ReviewsPanel, { + props: { + appKey: "apm:amd64-apm:office:wps", + tags, + loggedIn: false, + }, + }); + + expect(screen.getByText("登录后发表评论")).toBeTruthy(); + expect(screen.getByText("1.0.0")).toBeTruthy(); + expect(screen.getByText("amd64")).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `npm run test -- src/__tests__/unit/ReviewsPanel.test.ts` + +Expected: FAIL because `ReviewsPanel.vue` does not exist. + +- [ ] **Step 3: Create ReviewsPanel component** + +Create `src/components/ReviewsPanel.vue` with: + +```vue + + + +``` + +- [ ] **Step 4: Run ReviewsPanel test and verify pass** + +Run: `npm run test -- src/__tests__/unit/ReviewsPanel.test.ts` + +Expected: PASS. + +- [ ] **Step 5: Mount ReviewsPanel in AppDetailModal** + +Modify `src/components/AppDetailModal.vue`: + +1. Import `ReviewsPanel`. +2. Add props: + +```typescript +reviewAppKey: string; +reviewTags: ReviewTags | null; +loggedIn: boolean; +``` + +3. Add emit: + +```typescript +(e: "request-login"): void; +``` + +4. Add below the screenshot block: + +```vue + +``` + +- [ ] **Step 6: Build review props in App.vue** + +Modify `src/App.vue`: + +1. Import `buildAppKey` and `buildReviewTags`. +2. Add refs: + +```typescript +const systemInfo = ref({ distro: "unknown" }); +``` + +3. On mount, call: + +```typescript +systemInfo.value = await window.ipcRenderer.invoke("get-system-info"); +``` + +4. Add computed values: + +```typescript +const currentDisplayAppForReview = computed(() => { + const app = currentApp.value; + if (!app) return null; + if (!app.isMerged) return app; + return app.viewingOrigin === "spark" ? app.sparkApp || app : app.apmApp || app; +}); + +const currentReviewAppKey = computed(() => { + const app = currentDisplayAppForReview.value; + return app ? buildAppKey(app, window.apm_store.arch || "amd64") : ""; +}); + +const currentReviewTags = computed(() => { + const app = currentDisplayAppForReview.value; + return app + ? buildReviewTags(app, { + clientArch: window.apm_store.arch || "amd64", + distro: systemInfo.value.distro, + }) + : null; +}); +``` + +5. Pass these props to `AppDetailModal` and wire `@request-login="showLoginModal = true"`. + +- [ ] **Step 7: Run targeted tests** + +Run: `npm run test -- src/__tests__/unit/ReviewsPanel.test.ts src/__tests__/unit/reviewTags.test.ts` + +Expected: PASS. + +- [ ] **Step 8: Commit review panel** + +Run: + +```bash +git add src/components/ReviewsPanel.vue src/components/AppDetailModal.vue src/App.vue src/__tests__/unit/ReviewsPanel.test.ts +git commit -m "feat(reviews): show app reviews in details" +``` + +Expected: commit succeeds if commits are requested for implementation execution. + +## Task 6: Add App-List Sync Filtering + +**Files:** +- Create: `src/modules/appListSync.ts` +- Test: `src/__tests__/unit/appListSync.test.ts` + +- [ ] **Step 1: Write failing sync filtering tests** + +Create `src/__tests__/unit/appListSync.test.ts` with: + +```typescript +import { describe, expect, it } from "vitest"; + +import { buildSyncItems } from "@/modules/appListSync"; +import type { App } from "@/global/typedefinition"; + +const baseApp: App = { + name: "Spark Notes", + pkgname: "spark-notes", + version: "1.0.0", + filename: "spark-notes_1.0.0_amd64.deb", + torrent_address: "", + author: "", + contributor: "", + website: "", + update: "", + size: "", + more: "", + tags: "", + img_urls: [], + icons: "", + category: "office", + origin: "spark", + currentStatus: "installed", +}; + +describe("appListSync", () => { + it("syncs only store-recognized non-dependency apps", () => { + const items = buildSyncItems([ + baseApp, + { ...baseApp, pkgname: "unknown", category: "unknown" }, + { ...baseApp, pkgname: "dep", isDependency: true }, + ]); + + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ pkgname: "spark-notes", origin: "spark", category: "office" }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `npm run test -- src/__tests__/unit/appListSync.test.ts` + +Expected: FAIL because `appListSync` module does not exist. + +- [ ] **Step 3: Add sync filtering module** + +Create `src/modules/appListSync.ts` with: + +```typescript +import type { App, SyncedAppListItem } from "@/global/typedefinition"; +import { parsePackageArch } from "@/modules/reviewTags"; + +export const buildSyncItems = (apps: App[]): SyncedAppListItem[] => { + return apps + .filter((app) => app.currentStatus === "installed") + .filter((app) => app.category !== "unknown") + .filter((app) => !app.isDependency) + .filter((app) => Boolean(app.pkgname && app.origin)) + .map((app) => ({ + pkgname: app.pkgname, + origin: app.origin, + category: app.category, + version: app.version || "", + packageArch: app.arch || parsePackageArch(app.filename), + appName: app.name || app.pkgname, + iconUrl: app.icons || "", + })); +}; + +export const isCloudItemInstalled = ( + item: SyncedAppListItem, + installedApps: App[], +): boolean => { + return installedApps.some((app) => app.pkgname === item.pkgname && app.origin === item.origin && app.currentStatus === "installed"); +}; +``` + +- [ ] **Step 4: Run sync filtering tests and verify pass** + +Run: `npm run test -- src/__tests__/unit/appListSync.test.ts` + +Expected: PASS. + +- [ ] **Step 5: Commit sync helpers** + +Run: + +```bash +git add src/modules/appListSync.ts src/__tests__/unit/appListSync.test.ts +git commit -m "feat(sync): add app list filtering" +``` + +Expected: commit succeeds if commits are requested for implementation execution. + +## Task 7: Add Sync And Restore UI + +**Files:** +- Create: `src/components/AppListRestoreModal.vue` +- Modify: `src/components/InstalledAppsModal.vue` +- Modify: `src/App.vue` +- Test: `src/__tests__/unit/AppListRestoreModal.test.ts` +- Test: `src/__tests__/unit/InstalledAppsModal.test.ts` + +- [ ] **Step 1: Write failing restore modal test** + +Create `src/__tests__/unit/AppListRestoreModal.test.ts` with: + +```typescript +import { fireEvent, render, screen } from "@testing-library/vue"; +import { describe, expect, it } from "vitest"; + +import AppListRestoreModal from "@/components/AppListRestoreModal.vue"; + +describe("AppListRestoreModal", () => { + it("emits selected installable items", async () => { + const rendered = render(AppListRestoreModal, { + props: { + show: true, + loading: false, + error: "", + items: [ + { + pkgname: "spark-notes", + origin: "spark", + category: "office", + version: "1.0.0", + packageArch: "amd64", + appName: "Spark Notes", + iconUrl: "", + }, + ], + installedKeys: [], + }, + }); + + await fireEvent.click(screen.getByLabelText("选择 Spark Notes")); + await fireEvent.click(screen.getByRole("button", { name: "加入安装队列" })); + + expect(rendered.emitted("install-selected")?.[0]?.[0]).toHaveLength(1); + }); +}); +``` + +- [ ] **Step 2: Run test to verify failure** + +Run: `npm run test -- src/__tests__/unit/AppListRestoreModal.test.ts` + +Expected: FAIL because `AppListRestoreModal.vue` does not exist. + +- [ ] **Step 3: Create restore modal** + +Create `src/components/AppListRestoreModal.vue` with: + +1. Props `show`, `loading`, `error`, `items: SyncedAppListItem[]`, `installedKeys: string[]`. +2. Emits `close` and `install-selected`. +3. Local selected map keyed by `${origin}:${pkgname}`. +4. Checkbox per item with accessible label `选择 ${item.appName || item.pkgname}`. +5. Disable checkbox when installed key is present. +6. Button text `加入安装队列`. + +- [ ] **Step 4: Run restore modal test and verify pass** + +Run: `npm run test -- src/__tests__/unit/AppListRestoreModal.test.ts` + +Expected: PASS. + +- [ ] **Step 5: Add sync buttons to InstalledAppsModal** + +Modify `src/components/InstalledAppsModal.vue`: + +1. Add props: + +```typescript +loggedIn: boolean; +syncing: boolean; +``` + +2. Add emits: + +```typescript +(e: "sync-to-account"): void; +(e: "restore-from-account"): void; +(e: "request-login"): void; +``` + +3. Add header buttons before Refresh: + +```vue + + +``` + +4. Update existing `InstalledAppsModal.test.ts` props to pass `loggedIn: false` and `syncing: false`. + +- [ ] **Step 6: Wire sync/restore in App.vue** + +Modify `src/App.vue`: + +1. Import `AppListRestoreModal`, `buildSyncItems`, `fetchSyncedAppList`, and `uploadSyncedAppList`. +2. Add state: + +```typescript +const syncLoading = ref(false); +const restoreLoading = ref(false); +const restoreError = ref(""); +const showRestoreModal = ref(false); +const restoreItems = ref([]); +``` + +3. Pass `:logged-in="isLoggedIn"` and `:syncing="syncLoading"` to `InstalledAppsModal`. +4. Add handlers: + +```typescript +const syncInstalledAppsToAccount = async () => { + syncLoading.value = true; + try { + const items = buildSyncItems(installedApps.value); + await uploadSyncedAppList({ + clientArch: window.apm_store.arch || "amd64", + distro: systemInfo.value.distro, + items, + }); + } finally { + syncLoading.value = false; + } +}; + +const openRestoreFromAccount = async () => { + restoreLoading.value = true; + restoreError.value = ""; + showRestoreModal.value = true; + try { + const list = await fetchSyncedAppList(); + restoreItems.value = list?.items || []; + } catch (error) { + restoreError.value = error instanceof Error ? error.message : "读取云端列表失败"; + } finally { + restoreLoading.value = false; + } +}; +``` + +5. Mount `AppListRestoreModal` and on `install-selected`, map selected cloud items to catalog `App` entries from `apps.value` by `pkgname`, `origin`, and `category`, then call existing `handleInstall(app)` for each. + +- [ ] **Step 7: Run targeted tests** + +Run: `npm run test -- src/__tests__/unit/AppListRestoreModal.test.ts src/__tests__/unit/InstalledAppsModal.test.ts src/__tests__/unit/appListSync.test.ts` + +Expected: PASS. + +- [ ] **Step 8: Commit sync UI** + +Run: + +```bash +git add src/components/AppListRestoreModal.vue src/components/InstalledAppsModal.vue src/App.vue src/__tests__/unit/AppListRestoreModal.test.ts src/__tests__/unit/InstalledAppsModal.test.ts +git commit -m "feat(sync): add account app restore UI" +``` + +Expected: commit succeeds if commits are requested for implementation execution. + +## Task 8: Final Client Verification + +**Files:** +- Verify: no planned file edits in this task. + +- [ ] **Step 1: Run account-related unit tests** + +Run: + +```bash +npm run test -- src/__tests__/unit/accountTypes.test.ts src/__tests__/unit/authState.test.ts src/__tests__/unit/LoginModal.test.ts src/__tests__/unit/reviewTags.test.ts src/__tests__/unit/ReviewsPanel.test.ts src/__tests__/unit/appListSync.test.ts src/__tests__/unit/AppListRestoreModal.test.ts src/__tests__/unit/InstalledAppsModal.test.ts +``` + +Expected: all listed tests PASS. + +- [ ] **Step 2: Run full unit test suite** + +Run: `npm run test` + +Expected: all unit tests PASS. + +- [ ] **Step 3: Run lint** + +Run: `npm run lint` + +Expected: exits 0 with no lint errors. + +- [ ] **Step 4: Run Vite build** + +Run: `npm run build:vite` + +Expected: exits 0 and produces renderer/main build artifacts. + +- [ ] **Step 5: Check working tree** + +Run: `git status --short` + +Expected: only intentional changes remain. `.superpowers/` must not appear because `.gitignore` ignores it. + +## Self-Review Checklist + +Spec coverage: + +- Login and avatar/name display: Tasks 2 and 3. +- Direct client-to-Flarum token login: Task 3. +- Backend JWT storage and use: Task 2. +- Detail-page review panel: Task 5. +- Immutable automatic tags: Task 4. +- Review filtering foundation: Task 5. +- Store-recognized app-list sync: Tasks 6 and 7. +- Restore through existing install queue: Task 7. + +Placeholder scan: + +- The plan has no deferred implementation sections and no placeholder tasks. + +Type consistency: + +- Backend snake_case payloads are mapped in `backendApi.ts`. +- UI state uses camelCase `SparkUser`, `ReviewTags`, and `SyncedAppListItem`. +- `window.apm_store.arch` is treated as bare architecture such as `amd64`. diff --git a/docs/superpowers/specs/2026-05-18-spark-account-reviews-sync-design.md b/docs/superpowers/specs/2026-05-18-spark-account-reviews-sync-design.md new file mode 100644 index 00000000..df9cb69e --- /dev/null +++ b/docs/superpowers/specs/2026-05-18-spark-account-reviews-sync-design.md @@ -0,0 +1,374 @@ +# Spark Account Reviews Sync Design + +## Goal + +Add a first-version account feature to Spark Store that uses the existing Flarum forum at `https://bbs.spark-app.store/` as the identity provider, while storing Spark Store-specific data in a new Python + MySQL backend. + +The MVP covers: + +1. Login with a Flarum account. +2. Show the logged-in user's avatar and display name in the client. +3. Show and submit app detail page comments with 1-5 star ratings. +4. Attach immutable automatic local tags to reviews and support review filtering by those tags. +5. Sync the user's local store-recognized app list so a new device can quickly reinstall old apps. +6. Create a new backend Git repository named `spark-store-backend`. + +## Scope + +### Included In MVP + +1. Client-side Flarum token login. +2. Backend validation of Flarum tokens and backend JWT issuance. +3. User profile display in the Spark Store client. +4. Review list, rating summary, review creation, and editing the current user's own review. +5. Review filtering by automatic tags: app version, package architecture, client architecture, distro, origin, and category. +6. A default per-user cloud app list that is overwritten on each sync. +7. Restore UI that lets users choose cloud-list apps and add them to the existing install queue. + +### Excluded From MVP + +1. Admin moderation UI. +2. Review reports, bans, anti-spam scoring, or manual approval workflows. +3. Synchronizing reviews into Flarum discussions. +4. Multiple named app-list snapshots or historical list versions. +5. Syncing unknown system packages, dependencies, or every package from `dpkg`/APM. +6. Automatic unattended install on a new device. + +## Existing Client Context + +The current Spark Store client in this repository is an Electron + Vue 3 + TypeScript app. + +Important existing integration points: + +1. `src/components/AppDetailModal.vue` owns the app detail modal. It already renders app metadata, screenshots, install/open/remove actions, and download counts. +2. `src/components/InstalledAppsModal.vue` owns the installed-app list UI. +3. `src/App.vue` coordinates modal state, installed-app loading, detail opening, and existing install queue calls. +4. `electron/main/backend/install-manager.ts` already exposes `check-installed` and `list-installed` IPC handlers. +5. Existing install queue behavior should be reused for restore installs rather than creating a second install system. + +## Architecture + +Use the lightweight new-backend architecture confirmed during brainstorming. + +Components: + +1. Spark Store client: Vue/Electron UI, local app metadata, local package detection, and install queue integration. +2. Flarum forum: authoritative identity provider for forum username, display name, and avatar. +3. New Python backend: FastAPI service that verifies Flarum tokens, signs Spark Store JWTs, and owns reviews, ratings, and app-list sync data. +4. MySQL database: persistent storage for local user mappings, app keys, reviews, rating aggregates, and synced app-list items. + +The new backend must not receive the user's forum password in the selected MVP flow. + +## Authentication Flow + +1. The client shows a login form for the Flarum username/email and password. +2. The client posts credentials directly to Flarum's token API. +3. Flarum returns an access token and user id. +4. The client sends the Flarum token and user id to the new backend `POST /auth/flarum`. +5. The backend calls Flarum API with that token to verify it and retrieve the user profile. +6. The backend upserts a local user row keyed by `flarum_user_id`. +7. The backend returns a Spark Store JWT plus public profile fields: display name, username, avatar URL, and Flarum user id. +8. The client stores the Spark Store JWT for backend calls and displays the avatar/name in the header or account area. + +Logout clears local Spark Store auth state. MVP logout does not need to revoke the Flarum token remotely. + +## Review And Rating Design + +### App Identity + +Reviews are keyed by a stable app key derived from store metadata: + +```text +app_key = {origin}:{store_arch}:{category}:{pkgname} +``` + +Examples: + +```text +spark:amd64-store:tools:spark-store +apm:amd64-apm:office:wps +``` + +This separates Spark and APM apps when they share a package name, while still allowing the UI to show each source independently in the existing hybrid detail modal. + +### Automatic Review Tags + +When a logged-in user writes a review, the client sends automatic tags derived from the currently viewed app and local system. The user can preview these tags but cannot edit them. + +Required tags: + +1. `origin`: `spark` or `apm`. +2. `category`: current store category. +3. `pkgname`: current package name. +4. `version`: current app/package version shown in the detail page. +5. `package_arch`: package architecture if known from installed-app data or store filename metadata. +6. `client_arch`: `window.apm_store.arch` such as `amd64`, `arm64`, or `loong64`. +7. `distro`: local Linux distribution id/version when available from the Electron main process. + +If `package_arch` or `distro` cannot be detected, the client sends an empty string or `unknown`; the backend stores the value exactly as submitted after validation. + +### Review UI + +Add a `ReviewsPanel`-style component inside `AppDetailModal.vue`, below the existing app description and screenshot sections. + +Behavior: + +1. Anonymous users can read reviews and rating summary. +2. Anonymous users see a login prompt instead of the review composer. +3. Logged-in users can select a 1-5 rating and submit text content. +4. The composer displays the automatic tags as read-only pills. +5. The list supports filters for current version, current architecture, current distro, origin, category, and rating. +6. Users can switch filters to view comments under other versions or architectures. +7. The review list uses pagination or cursor-based loading to avoid loading all reviews at once. + +### Review Rules + +MVP review rules: + +1. Rating must be an integer from 1 to 5. +2. Content must be non-empty after trimming and have a backend-enforced maximum length. +3. A user can have one active review per `app_key` plus exact automatic-tag tuple. +4. Submitting again for the same `app_key` and tag tuple updates the existing review. +5. Backend timestamps use UTC. + +## App List Sync Design + +### Upload Source + +The client uses the existing installed-app flow as the source of local software state. + +For MVP, upload only apps that satisfy all conditions: + +1. App exists in the Spark/APM store catalog loaded by the client. +2. `category !== "unknown"`. +3. `isDependency !== true`. +4. The app has a usable `pkgname` and `origin`. + +This intentionally excludes unknown system packages, dependencies, and packages that the store cannot reinstall safely. + +### Sync UI + +Extend `InstalledAppsModal.vue` with account-aware actions: + +1. `同步到账号`: uploads the filtered installed app list as the user's default cloud list. +2. `从账号恢复`: fetches the default cloud list and opens a restore selection view. + +The restore view shows each cloud item with one of these states: + +1. Already installed locally. +2. Available to install on this client. +3. Not available for the current architecture/source. + +The user explicitly selects items and starts restore. Restore uses the existing `handleInstall` and install queue path in the client. + +### Cloud List Semantics + +MVP maintains one default cloud app list per user. + +Each successful sync replaces the previous default list. This avoids list merge conflicts in the first version. + +## Backend Repository + +Create a new sibling repository: + +```text +/home/spark/Desktop/shenmo-spark-store/spark-store-backend +``` + +Initialize it as a new Git repository and set origin to: + +```text +https://gitee.com/momen_official/spark-store-backend.git +``` + +The initial repository should include a `README.md`. Feature implementation can then add backend code, migrations, tests, and configuration templates. + +## Backend Technology + +Use: + +1. FastAPI for HTTP APIs. +2. SQLAlchemy for ORM models. +3. Alembic for database migrations. +4. PyMySQL or mysqlclient for MySQL connectivity. +5. Pydantic settings for environment configuration. +6. Pytest for backend tests. + +Configuration should come from environment variables, with `.env.example` committed and real `.env` ignored. + +## Backend API + +### Auth + +`POST /auth/flarum` + +Request: + +```json +{ + "flarum_user_id": "123", + "flarum_token": "..." +} +``` + +Response: + +```json +{ + "access_token": "spark-store-jwt", + "token_type": "bearer", + "user": { + "id": 1, + "flarum_user_id": "123", + "username": "shenmo", + "display_name": "shenmo", + "avatar_url": "https://..." + } +} +``` + +`GET /me` + +Returns the current backend user profile from the Spark Store JWT. + +### Reviews + +`GET /apps/{app_key}/rating-summary` + +Returns average rating, review count, and per-star counts. + +`GET /apps/{app_key}/reviews` + +Query parameters: + +1. `version` +2. `package_arch` +3. `client_arch` +4. `distro` +5. `origin` +6. `category` +7. `rating` +8. `page` and `page_size` + +`POST /apps/{app_key}/reviews` + +Requires JWT. Creates or updates the current user's review for the same app and automatic-tag tuple. + +Request: + +```json +{ + "rating": 5, + "content": "Works well on my machine.", + "tags": { + "origin": "apm", + "category": "office", + "pkgname": "wps", + "version": "1.0.0", + "package_arch": "amd64", + "client_arch": "amd64", + "distro": "deepin 25" + } +} +``` + +### App List Sync + +`GET /me/app-list` + +Requires JWT. Returns the current user's default cloud app list. + +`PUT /me/app-list` + +Requires JWT. Replaces the current user's default cloud app list. + +Request: + +```json +{ + "client_arch": "amd64", + "distro": "deepin 25", + "items": [ + { + "pkgname": "spark-store", + "origin": "spark", + "category": "tools", + "version": "5.1.1", + "package_arch": "amd64", + "app_name": "Spark Store", + "icon_url": "https://..." + } + ] +} +``` + +`POST /me/app-list/install-plan` + +Requires JWT. Accepts current client catalog/install facts and returns a normalized plan with installed, installable, and unavailable items. The client may also compute this locally, but the endpoint gives the backend a stable contract for future clients. + +## Database Model + +Tables: + +1. `users`: local user id, Flarum user id, username, display name, avatar URL, timestamps. +2. `apps`: app key, pkgname, origin, store arch, category, latest seen version, timestamps. +3. `reviews`: app id, user id, rating, content, automatic tag columns, timestamps. +4. `user_app_lists`: user id, snapshot name, client arch, distro, timestamps. +5. `user_app_list_items`: list id, pkgname, origin, category, version, package arch, app name, icon URL, timestamps. + +Indexes: + +1. Unique `users.flarum_user_id`. +2. Unique `apps.app_key`. +3. Index `reviews.app_id` plus tag filter columns. +4. Unique review key for user, app, version, package arch, client arch, distro, origin, and category. +5. Unique default app list per user. + +## Error Handling + +Client behavior: + +1. If Flarum login fails, show a login error without contacting the backend. +2. If backend token exchange fails, show a backend login error and clear partial auth state. +3. If review loading fails, keep the app detail page usable and show a retry affordance in the review panel. +4. If app-list sync fails, keep the local installed-app modal usable and show the failure message near the sync action. +5. If restore install queuing fails for one item, keep the remaining selected items visible and report which item failed. + +Backend behavior: + +1. Invalid Flarum token returns `401`. +2. Invalid JWT returns `401`. +3. Invalid app key, rating, or tag payload returns `422`. +4. Database errors return `500` with safe generic messages and structured server logs. + +## Security Notes + +1. The new backend never receives forum passwords in the selected MVP architecture. +2. Real secrets, JWT keys, database URLs, and Flarum tokens must not be committed. +3. The Electron client must avoid logging Flarum tokens and backend JWTs. +4. Backend JWT expiry should be finite; refresh can be handled by reauth in MVP. +5. CORS should be restricted to expected client origins in production. + +## Testing And Verification + +Client verification: + +1. Unit tests for review tag construction. +2. Unit tests for installed-app sync filtering. +3. Component tests for review panel anonymous/logged-in states. +4. Component tests for sync and restore UI states. +5. Existing `npm run lint` and `npm run build:vite` after implementation. + +Backend verification: + +1. Unit/API tests for `/auth/flarum` with mocked Flarum responses. +2. API tests for review creation, update, listing, filtering, and rating summary. +3. API tests for app-list upload and retrieval. +4. Migration verification against MySQL or a compatible test database. + +## Open Implementation Notes + +1. Detecting `distro` should be done through Electron main process IPC, preferably by reading `/etc/os-release` and exposing a small safe object to the renderer. +2. Package architecture can come from installed-app data when available; otherwise parse from filename only if reliable, falling back to `unknown`. +3. The existing `AppDetailModal.vue` is already large, so review UI should be isolated into a new child component rather than expanding all logic inline. +4. Restore installation should reuse existing app lookup and `handleInstall` code to preserve Spark/APM origin behavior.