diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000000..922133775c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Read", + "hooks": [ + { + "type": "command", + "command": "{\n \"permissions\": {\n \"deny\": [\n \"Read(.env*)\",\n \"Edit(.env*)\"\n ]\n }\n}" + } + ] + } + ] + } +} diff --git a/.env b/.env index 1d44286e25..9aed8e6c00 100644 --- a/.env +++ b/.env @@ -40,6 +40,10 @@ POSTGRES_PASSWORD=changethis SENTRY_DSN= +# OMDB API - Get your free API key at http://www.omdbapi.com/apikey.aspx +OMDB_API_KEY=8cc1695e +OMDB_CACHE_TTL_DAYS=30 + # Configure these with your own Docker registry images DOCKER_IMAGE_BACKEND=backend DOCKER_IMAGE_FRONTEND=frontend diff --git a/.gitignore b/.gitignore index f903ab6066..aa3a911e9e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ node_modules/ /playwright-report/ /blob-report/ /playwright/.cache/ +.env diff --git a/README.md b/README.md index a9049b4779..cd824d2e2e 100644 --- a/README.md +++ b/README.md @@ -1,233 +1,453 @@ -# Full Stack FastAPI Template - -Test Docker Compose -Test Backend -Coverage - -## Technology Stack and Features - -- ⚑ [**FastAPI**](https://fastapi.tiangolo.com) for the Python backend API. - - 🧰 [SQLModel](https://sqlmodel.tiangolo.com) for the Python SQL database interactions (ORM). - - πŸ” [Pydantic](https://docs.pydantic.dev), used by FastAPI, for the data validation and settings management. - - πŸ’Ύ [PostgreSQL](https://www.postgresql.org) as the SQL database. -- πŸš€ [React](https://react.dev) for the frontend. - - πŸ’ƒ Using TypeScript, hooks, [Vite](https://vitejs.dev), and other parts of a modern frontend stack. - - 🎨 [Tailwind CSS](https://tailwindcss.com) and [shadcn/ui](https://ui.shadcn.com) for the frontend components. - - πŸ€– An automatically generated frontend client. - - πŸ§ͺ [Playwright](https://playwright.dev) for End-to-End testing. - - πŸ¦‡ Dark mode support. -- πŸ‹ [Docker Compose](https://www.docker.com) for development and production. -- πŸ”’ Secure password hashing by default. -- πŸ”‘ JWT (JSON Web Token) authentication. -- πŸ“« Email based password recovery. -- πŸ“¬ [Mailcatcher](https://mailcatcher.me) for local email testing during development. -- βœ… Tests with [Pytest](https://pytest.org). -- πŸ“ž [Traefik](https://traefik.io) as a reverse proxy / load balancer. -- 🚒 Deployment instructions using Docker Compose, including how to set up a frontend Traefik proxy to handle automatic HTTPS certificates. -- 🏭 CI (continuous integration) and CD (continuous deployment) based on GitHub Actions. - -### Dashboard Login - -[![API docs](img/login.png)](https://github.com/fastapi/full-stack-fastapi-template) - -### Dashboard - Admin - -[![API docs](img/dashboard.png)](https://github.com/fastapi/full-stack-fastapi-template) - -### Dashboard - Items - -[![API docs](img/dashboard-items.png)](https://github.com/fastapi/full-stack-fastapi-template) - -### Dashboard - Dark Mode - -[![API docs](img/dashboard-dark.png)](https://github.com/fastapi/full-stack-fastapi-template) - -### Interactive API Documentation - -[![API docs](img/docs.png)](https://github.com/fastapi/full-stack-fastapi-template) - -## How To Use It - -You can **just fork or clone** this repository and use it as is. - -✨ It just works. ✨ - -### How to Use a Private Repository - -If you want to have a private repository, GitHub won't allow you to simply fork it as it doesn't allow changing the visibility of forks. - -But you can do the following: - -- Create a new GitHub repo, for example `my-full-stack`. -- Clone this repository manually, set the name with the name of the project you want to use, for example `my-full-stack`: - -```bash -git clone git@github.com:fastapi/full-stack-fastapi-template.git my-full-stack -``` - -- Enter into the new directory: - +# Vantage + +A social movie club platform with an Art Deco-inspired design. Discover movies, build watchlists, rate films, and connect with friends through shared cinema experiences. + +## Overview + +Vantage is a full-stack web application that enables teams and friend groups to: + +- **Discover Movies** - Search and explore films via OMDB API integration with local caching +- **Personal Watchlists** - Track movies you want to watch and mark ones you've seen +- **Rate & Review** - Rate movies on a 5-star scale with your personal collection +- **Movie Clubs** - Create clubs with shared watchlists, member management, and voting +- **Watch Parties** (Coming Soon) - Schedule events and coordinate viewing sessions +- **Discussions** (Coming Soon) - Threaded forums for movie discussions + +Built on the [Full Stack FastAPI Template](https://github.com/fastapi/full-stack-fastapi-template) with modern React frontend. + +## Tech Stack + +### Backend +| Technology | Purpose | +|------------|---------| +| FastAPI | Python web framework with async support | +| PostgreSQL | Primary database | +| SQLModel | ORM combining SQLAlchemy + Pydantic | +| Alembic | Database migrations | +| JWT | Authentication tokens | +| httpx | Async HTTP client for OMDB API | +| Sentry | Error tracking (optional) | + +### Frontend +| Technology | Purpose | +|------------|---------| +| React 19 | UI framework | +| TypeScript | Type safety | +| Vite | Build tool and dev server | +| TanStack Router | Type-safe routing | +| TanStack Query | Server state management | +| Tailwind CSS | Utility-first styling | +| shadcn/ui | Component library (Radix UI + Tailwind) | +| Playwright | End-to-end testing | + +### Infrastructure +| Technology | Purpose | +|------------|---------| +| Docker Compose | Container orchestration | +| Traefik | Reverse proxy with automatic HTTPS | +| Adminer | Database administration UI | +| Mailcatcher | Email testing in development | + +## Installation + +### Prerequisites + +- **Docker** and **Docker Compose** (recommended) +- **Python 3.10+** (for local backend development) +- **Bun** or **Node.js 18+** (for frontend development) +- **uv** (Python package manager) + +### Quick Start with Docker + +1. **Clone the repository** + ```bash + git clone + cd vantage + ``` + +2. **Configure environment variables** + ```bash + cp .env.example .env + ``` + + Edit `.env` and set required values: + ```env + # Generate a secure key + SECRET_KEY= + + # Admin account + FIRST_SUPERUSER=admin@example.com + FIRST_SUPERUSER_PASSWORD= + + # Database + POSTGRES_PASSWORD= + + # OMDB API (get free key at http://www.omdbapi.com/apikey.aspx) + OMDB_API_KEY= + ``` + +3. **Start the application** + ```bash + docker compose watch + ``` + +4. **Access the services** + | Service | URL | + |---------|-----| + | Frontend | http://localhost:5173 | + | Backend API | http://localhost:8000 | + | API Documentation | http://localhost:8000/docs | + | Database Admin | http://localhost:8080 | + | Email Inbox | http://localhost:1080 | + | Traefik Dashboard | http://localhost:8090 | + +### Local Development Setup + +#### Backend ```bash -cd my-full-stack +cd backend +uv sync # Install dependencies +source .venv/bin/activate # Activate virtual environment +fastapi dev app/main.py # Start dev server (port 8000) ``` -- Set the new origin to your new repository, copy it from the GitHub interface, for example: - +#### Frontend ```bash -git remote set-url origin git@github.com:octocat/my-full-stack.git +cd frontend +bun install # Install dependencies +bun run dev # Start dev server (port 5173) ``` -- Add this repo as another "remote" to allow you to get updates later: +### Environment Variables Reference + +| Variable | Description | Default | +|----------|-------------|---------| +| `PROJECT_NAME` | Application name | Vantage | +| `ENVIRONMENT` | Environment mode | local | +| `DOMAIN` | Primary domain | localhost | +| `SECRET_KEY` | JWT signing key | (required) | +| `POSTGRES_SERVER` | Database host | db | +| `POSTGRES_PORT` | Database port | 5432 | +| `POSTGRES_DB` | Database name | app | +| `POSTGRES_USER` | Database user | postgres | +| `POSTGRES_PASSWORD` | Database password | (required) | +| `OMDB_API_KEY` | OMDB API key | (required) | +| `OMDB_CACHE_TTL_DAYS` | Movie cache duration | 30 | +| `SMTP_HOST` | Mail server host | mailcatcher | +| `SMTP_PORT` | Mail server port | 1025 | +| `BACKEND_CORS_ORIGINS` | Allowed origins (JSON array) | ["http://localhost:5173"] | + +## Project Structure -```bash -git remote add upstream git@github.com:fastapi/full-stack-fastapi-template.git ``` - -- Push the code to your new repository: - -```bash -git push -u origin master +vantage/ +β”œβ”€β”€ backend/ # FastAPI backend application +β”‚ β”œβ”€β”€ app/ +β”‚ β”‚ β”œβ”€β”€ main.py # Application entry point +β”‚ β”‚ β”œβ”€β”€ models.py # SQLModel database models +β”‚ β”‚ β”œβ”€β”€ crud.py # Database operations +β”‚ β”‚ β”œβ”€β”€ api/ +β”‚ β”‚ β”‚ β”œβ”€β”€ main.py # API router configuration +β”‚ β”‚ β”‚ β”œβ”€β”€ deps.py # Dependency injection +β”‚ β”‚ β”‚ └── routes/ # API endpoint handlers +β”‚ β”‚ β”‚ β”œβ”€β”€ login.py # Authentication +β”‚ β”‚ β”‚ β”œβ”€β”€ users.py # User management +β”‚ β”‚ β”‚ β”œβ”€β”€ movies.py # Movie search & details +β”‚ β”‚ β”‚ β”œβ”€β”€ ratings.py # Movie ratings +β”‚ β”‚ β”‚ β”œβ”€β”€ watchlist.py # Personal watchlist +β”‚ β”‚ β”‚ └── clubs.py # Movie clubs +β”‚ β”‚ β”œβ”€β”€ core/ +β”‚ β”‚ β”‚ β”œβ”€β”€ config.py # Settings with Pydantic +β”‚ β”‚ β”‚ β”œβ”€β”€ security.py # Password hashing & JWT +β”‚ β”‚ β”‚ └── db.py # Database connection +β”‚ β”‚ β”œβ”€β”€ services/ +β”‚ β”‚ β”‚ └── omdb.py # OMDB API client +β”‚ β”‚ └── alembic/ # Database migrations +β”‚ β”œβ”€β”€ tests/ # Backend test suite +β”‚ └── pyproject.toml # Python dependencies +β”‚ +β”œβ”€β”€ frontend/ # React frontend application +β”‚ β”œβ”€β”€ src/ +β”‚ β”‚ β”œβ”€β”€ main.tsx # Application entry point +β”‚ β”‚ β”œβ”€β”€ routes/ # TanStack Router pages +β”‚ β”‚ β”‚ β”œβ”€β”€ __root.tsx # Root layout +β”‚ β”‚ β”‚ β”œβ”€β”€ _layout.tsx # Authenticated layout +β”‚ β”‚ β”‚ └── _layout/ +β”‚ β”‚ β”‚ β”œβ”€β”€ index.tsx # Dashboard +β”‚ β”‚ β”‚ β”œβ”€β”€ clubs.tsx # Movie clubs list +β”‚ β”‚ β”‚ β”œβ”€β”€ clubs.$clubId.tsx # Club details +β”‚ β”‚ β”‚ β”œβ”€β”€ movies.tsx # Movie search +β”‚ β”‚ β”‚ β”œβ”€β”€ movies.$imdbId.tsx # Movie details +β”‚ β”‚ β”‚ β”œβ”€β”€ watchlist.tsx # Personal watchlist +β”‚ β”‚ β”‚ β”œβ”€β”€ ratings.tsx # Rating history +β”‚ β”‚ β”‚ └── settings.tsx # User settings +β”‚ β”‚ β”œβ”€β”€ components/ +β”‚ β”‚ β”‚ β”œβ”€β”€ Movies/ # Movie components +β”‚ β”‚ β”‚ β”œβ”€β”€ Clubs/ # Club components +β”‚ β”‚ β”‚ β”œβ”€β”€ Ratings/ # Rating components +β”‚ β”‚ β”‚ β”œβ”€β”€ ui/ # shadcn/ui components +β”‚ β”‚ β”‚ └── Sidebar/ # Navigation +β”‚ β”‚ β”œβ”€β”€ client/ # Auto-generated API client +β”‚ β”‚ └── hooks/ # Custom React hooks +β”‚ β”œβ”€β”€ package.json # Node dependencies +β”‚ └── playwright.config.ts # E2E test configuration +β”‚ +β”œβ”€β”€ scripts/ # Utility scripts +β”‚ └── generate-client.sh # Generate TypeScript API client +β”‚ +β”œβ”€β”€ compose.yml # Docker Compose configuration +β”œβ”€β”€ compose.override.yml # Development overrides +β”œβ”€β”€ .env.example # Environment template +β”œβ”€β”€ development.md # Development guide +β”œβ”€β”€ deployment.md # Deployment guide +└── SPEC.md # Product specification ``` -### Update From the Original Template - -After cloning the repository, and after doing changes, you might want to get the latest changes from this original template. - -- Make sure you added the original repository as a remote, you can check it with: +## System Design -```bash -git remote -v +### Architecture Overview -origin git@github.com:octocat/my-full-stack.git (fetch) -origin git@github.com:octocat/my-full-stack.git (push) -upstream git@github.com:fastapi/full-stack-fastapi-template.git (fetch) -upstream git@github.com:fastapi/full-stack-fastapi-template.git (push) ``` - -- Pull the latest changes without merging: - -```bash -git pull --no-commit upstream master +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Client Browser β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Traefik Proxy β”‚ +β”‚ (Routing & HTTPS) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Frontend (Vite) β”‚ β”‚ Backend (FastAPI) β”‚ +β”‚ React + TS │◄────────────►│ Python 3.10+ β”‚ +β”‚ Port 5173 β”‚ REST API β”‚ Port 8000 β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β”‚ PostgreSQL β”‚ β”‚ OMDB API β”‚ β”‚ + β”‚ Database β”‚ β”‚ (External) β”‚ β”‚ + β”‚ Port 5432 β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Mailcatcher β”‚ + β”‚ (Dev SMTP) β”‚ + β”‚ Port 1025/1080 β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` -This will download the latest changes from this template without committing them, that way you can check everything is right before committing. +### Data Flow -- If there are conflicts, solve them in your editor. +1. **Authentication**: JWT-based with access tokens stored client-side +2. **API Communication**: RESTful endpoints with auto-generated TypeScript client from OpenAPI schema +3. **Movie Data**: OMDB API integration with 30-day local cache in PostgreSQL +4. **State Management**: TanStack Query for server state with automatic caching and revalidation -- Once you are done, commit the changes: +### Database Schema -```bash -git merge --continue +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ User β”‚ β”‚ UserWatchlist β”‚ β”‚ Movie β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ id │──┐ β”‚ id β”‚ β”Œβ”€β”€β”‚ id β”‚ +β”‚ email β”‚ β”‚ β”‚ user_id ───┼───── β”‚ imdb_id β”‚ +β”‚ hashed_pass β”‚ └────┼─ user_id β”‚ β”‚ β”‚ title β”‚ +β”‚ full_name β”‚ β”‚ movie_id β”€β”€β”€β”Όβ”€β”€β”€β”€β”˜ β”‚ year β”‚ +β”‚ is_active β”‚ β”‚ status β”‚ β”‚ poster β”‚ +β”‚ is_superuser β”‚ β”‚ added_at β”‚ β”‚ plot β”‚ +β”‚ created_at β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ actors β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ director β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ imdb_rating β”‚ + β”‚ β”‚ Rating β”‚ β”‚ cached_at β”‚ + β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ id β”‚ β”‚ + └───────────┼─ user_id β”‚ β”‚ + β”‚ movie_id β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ club_id (opt) β”‚ + β”‚ score (1-5) β”‚ + β”‚ created_at β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Club β”‚ β”‚ ClubMember β”‚ β”‚ ClubWatchlist β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ id │──┐ β”‚ id β”‚ β”‚ id β”‚ +β”‚ name β”‚ β”‚ β”‚ club_id ───┼───────┼─ club_id β”‚ +β”‚ description β”‚ └────┼─ club_id β”‚ β”‚ movie_id ───┼──► Movie +β”‚ visibility β”‚ β”‚ user_id ───┼──► User β”‚ added_by_user_id β”‚ +β”‚ rules β”‚ β”‚ role β”‚ β”‚ notes β”‚ +β”‚ theme_color β”‚ β”‚ joined_at β”‚ β”‚ added_at β”‚ +β”‚ created_at β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +β”‚ updated_at β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ ClubWatchlistVoteβ”‚ + β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ + β”‚ id β”‚ + β”‚ watchlist_entry_id + β”‚ user_id ───┼──► User + β”‚ vote_type β”‚ + β”‚ created_at β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` -### Configure - -You can then update configs in the `.env` files to customize your configurations. +### API Endpoints + +#### Authentication +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/v1/login/access-token` | Login and get JWT token | +| POST | `/api/v1/signup` | Register new user | +| POST | `/api/v1/password-recovery/{email}` | Request password reset | +| POST | `/api/v1/reset-password/` | Reset password with token | + +#### Users +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/users/me` | Get current user profile | +| PATCH | `/api/v1/users/me` | Update current user | +| GET | `/api/v1/users/` | List users (admin) | +| DELETE | `/api/v1/users/{id}` | Delete user (admin) | + +#### Movies +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/movies/search` | Search movies (query, year, type) | +| GET | `/api/v1/movies/{imdb_id}` | Get movie details | + +#### Watchlist +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/users/me/watchlist` | Get personal watchlist | +| POST | `/api/v1/users/me/watchlist` | Add movie to watchlist | +| PATCH | `/api/v1/users/me/watchlist/{id}` | Update watchlist item | +| DELETE | `/api/v1/users/me/watchlist/{id}` | Remove from watchlist | + +#### Ratings +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/ratings/me` | Get user's ratings | +| POST | `/api/v1/ratings/` | Create/update rating | +| DELETE | `/api/v1/ratings/{id}` | Delete rating | + +#### Clubs +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/clubs/` | List clubs (public + user's clubs) | +| POST | `/api/v1/clubs/` | Create new club | +| GET | `/api/v1/clubs/{club_id}` | Get club with members | +| PATCH | `/api/v1/clubs/{club_id}` | Update club (admin/owner) | +| DELETE | `/api/v1/clubs/{club_id}` | Delete club (owner only) | +| POST | `/api/v1/clubs/{club_id}/join` | Join club | +| DELETE | `/api/v1/clubs/{club_id}/leave` | Leave club | +| PATCH | `/api/v1/clubs/{club_id}/members/{user_id}` | Update member role | +| DELETE | `/api/v1/clubs/{club_id}/members/{user_id}` | Remove member | +| GET | `/api/v1/clubs/{club_id}/watchlist` | Get club watchlist | +| POST | `/api/v1/clubs/{club_id}/watchlist` | Add movie to watchlist | +| DELETE | `/api/v1/clubs/{club_id}/watchlist/{entry_id}` | Remove from watchlist | +| POST | `/api/v1/clubs/{club_id}/watchlist/{entry_id}/vote` | Vote on movie | -Before deploying it, make sure you change at least the values for: +## Development -- `SECRET_KEY` -- `FIRST_SUPERUSER_PASSWORD` -- `POSTGRES_PASSWORD` +### Database Migrations -You can (and should) pass these as environment variables from secrets. +```bash +# Create a new migration +docker compose exec backend alembic revision --autogenerate -m "Description" -Read the [deployment.md](./deployment.md) docs for more details. +# Apply migrations +docker compose exec backend alembic upgrade head -### Generate Secret Keys +# Rollback one migration +docker compose exec backend alembic downgrade -1 +``` -Some environment variables in the `.env` file have a default value of `changethis`. +### Regenerate API Client -You have to change them with a secret key, to generate secret keys you can run the following command: +After modifying backend endpoints, regenerate the TypeScript client: ```bash -python -c "import secrets; print(secrets.token_urlsafe(32))" +./scripts/generate-client.sh +# Or from frontend directory: +bun run generate-client ``` -Copy the content and use that as password / secret key. And run that again to generate another secure key. - -## How To Use It - Alternative With Copier - -This repository also supports generating a new project using [Copier](https://copier.readthedocs.io). - -It will copy all the files, ask you configuration questions, and update the `.env` files with your answers. - -### Install Copier - -You can install Copier with: +### Running Tests +**Backend (pytest)** ```bash -pip install copier +docker compose exec backend pytest +# With coverage +docker compose exec backend pytest --cov=app ``` -Or better, if you have [`pipx`](https://pipx.pypa.io/), you can run it with: - +**Frontend (Playwright E2E)** ```bash -pipx install copier +cd frontend +bun run test # Headless mode +bun run test:ui # Interactive mode ``` -**Note**: If you have `pipx`, installing copier is optional, you could run it directly. +### Code Quality -### Generate a Project With Copier - -Decide a name for your new project's directory, you will use it below. For example, `my-awesome-project`. - -Go to the directory that will be the parent of your project, and run the command with your project's name: +Pre-commit hooks are configured for: +- **Backend**: Ruff (linting/formatting), mypy (type checking) +- **Frontend**: Biome (linting/formatting) ```bash -copier copy https://github.com/fastapi/full-stack-fastapi-template my-awesome-project --trust -``` - -If you have `pipx` and you didn't install `copier`, you can run it directly: +# Run all hooks manually +uv run prek run --all-files -```bash -pipx run copier copy https://github.com/fastapi/full-stack-fastapi-template my-awesome-project --trust +# Install hooks +uv run prek install -f ``` -**Note** the `--trust` option is necessary to be able to execute a [post-creation script](https://github.com/fastapi/full-stack-fastapi-template/blob/master/.copier/update_dotenv.py) that updates your `.env` files. - -### Input Variables - -Copier will ask you for some data, you might want to have at hand before generating the project. - -But don't worry, you can just update any of that in the `.env` files afterwards. - -The input variables, with their default values (some auto generated) are: - -- `project_name`: (default: `"FastAPI Project"`) The name of the project, shown to API users (in .env). -- `stack_name`: (default: `"fastapi-project"`) The name of the stack used for Docker Compose labels and project name (no spaces, no periods) (in .env). -- `secret_key`: (default: `"changethis"`) The secret key for the project, used for security, stored in .env, you can generate one with the method above. -- `first_superuser`: (default: `"admin@example.com"`) The email of the first superuser (in .env). -- `first_superuser_password`: (default: `"changethis"`) The password of the first superuser (in .env). -- `smtp_host`: (default: "") The SMTP server host to send emails, you can set it later in .env. -- `smtp_user`: (default: "") The SMTP server user to send emails, you can set it later in .env. -- `smtp_password`: (default: "") The SMTP server password to send emails, you can set it later in .env. -- `emails_from_email`: (default: `"info@example.com"`) The email account to send emails from, you can set it later in .env. -- `postgres_password`: (default: `"changethis"`) The password for the PostgreSQL database, stored in .env, you can generate one with the method above. -- `sentry_dsn`: (default: "") The DSN for Sentry, if you are using it, you can set it later in .env. - -## Backend Development +## Deployment -Backend docs: [backend/README.md](./backend/README.md). +See [deployment.md](deployment.md) for production deployment instructions covering: -## Frontend Development +- Docker Compose production configuration +- Traefik setup with automatic HTTPS +- Environment variables for production +- GitHub Actions CI/CD -Frontend docs: [frontend/README.md](./frontend/README.md). +### Quick Production Setup -## Deployment +1. Configure production `.env` with secure secrets +2. Set up DNS records for your domain +3. Create Traefik proxy network: + ```bash + docker network create traefik-public + ``` +4. Deploy: + ```bash + docker compose -f compose.yml up -d + ``` -Deployment docs: [deployment.md](./deployment.md). +## Roadmap -## Development +- [x] **Phase 1**: OMDB integration, movie search, personal watchlist +- [x] **Phase 2**: Movie ratings, user profiles +- [x] **Phase 3**: Movie clubs with shared watchlists and voting +- [ ] **Phase 4**: Written reviews and discussion forums +- [ ] **Phase 5**: Watch party scheduling and events +- [ ] **Phase 6**: Notifications, activity feeds, achievements -General development docs: [development.md](./development.md). +## Contributing -This includes using Docker Compose, custom local domains, `.env` configurations, etc. +See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on: +- Opening issues and discussions +- Submitting pull requests +- Code style and testing requirements -## Release Notes +## License -Check the file [release-notes.md](./release-notes.md). +MIT License - see [LICENSE](LICENSE) for details. -## License +--- -The Full Stack FastAPI Template is licensed under the terms of the MIT license. +**Vantage** - Bringing friends together through cinema. diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000000..aaca0bb4b9 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,775 @@ +# Vantage - Movie Club Platform Specification + +## Overview + +**Vantage** is a social movie club platform that enables teams of users to create and manage movie clubs together. Users can discover films via the OMDB database, organize watch parties, rate and discuss movies, and build curated watchlists with their club members. + +### Vision + +A beautifully designed, retro-inspired application that brings the golden age of cinema club culture into the digital eraβ€”combining the communal joy of movie watching with modern collaboration tools. + +--- + +## Design Philosophy + +### Visual Identity + +**Aesthetic**: Art Deco / Retro Cinema + +**Color Palette**: +| Role | Color | Hex | +|------|-------|-----| +| Background (Primary) | Off-White / Cream | `#FAF7F2` | +| Background (Secondary) | Warm Ivory | `#F5F0E6` | +| Primary Accent | Rich Brown | `#8B4513` | +| Secondary Accent | Chocolate | `#5C3317` | +| Dark Accent | Espresso | `#3C2415` | +| Gold Highlight | Antique Gold | `#C9A227` | +| Text (Primary) | Charcoal | `#2C2C2C` | +| Text (Secondary) | Warm Gray | `#6B6B6B` | +| Success | Olive Green | `#6B7B3C` | +| Error | Burgundy | `#8B2942` | + +**Typography**: +- **Headers**: Art Deco inspired serif font (e.g., Poiret One, Playfair Display) +- **Body**: Clean readable sans-serif (e.g., Lato, Source Sans Pro) +- **Accents**: Decorative geometric fonts for logos/titles + +**Design Elements**: +- Geometric patterns and borders (chevrons, sunbursts, fan motifs) +- Film strip and reel iconography +- Vintage movie poster styling for cards +- Subtle grain/texture overlays +- Gold/bronze metallic accents for premium features +- Rounded art deco frames and containers + +--- + +## Target Users + +1. **Movie Enthusiasts** - Individuals passionate about film who want to share their hobby +2. **Friend Groups** - Casual groups looking to organize regular movie nights +3. **Film Students / Critics** - Those seeking structured discussion environments +4. **Corporate Teams** - Office groups building team culture through movie events +5. **Online Communities** - Remote groups coordinating virtual watch parties + +--- + +## Core Features + +### 1. User Management (Existing + Extended) + +**Existing** (from template): +- User registration and authentication (JWT) +- Profile management +- Password recovery +- Admin/superuser capabilities + +**Extended**: +- User avatar upload +- Movie preferences (favorite genres, decades, directors) +- Watch history +- Personal movie ratings +- Public profile pages + +### 2. Movie Clubs + +**Club Creation & Management**: +- Create clubs with name, description, and cover image +- Set club visibility (public, private, invite-only) +- Define club rules/guidelines +- Club themes (Horror Mondays, 80s Classics, Foreign Films, etc.) + +**Membership**: +- Club owner (creator) with full admin rights +- Admins (appointed by owner) - can manage members and content +- Members - can participate in all club activities +- Pending members - awaiting approval for private clubs + +**Club Features**: +- Club dashboard with activity feed +- Member directory +- Club statistics (movies watched, total ratings, etc.) +- Club achievements/badges + +### 3. Movie Discovery (OMDB Integration) + +**Search Capabilities**: +- Search by title, year, type (movie, series, episode) +- Advanced filters (genre, rating, decade) +- Browse popular/trending (cached results) + +**Movie Details**: +- Full OMDB data display (plot, cast, director, ratings) +- Movie posters and artwork +- External links (IMDb, Rotten Tomatoes) +- Club-specific ratings aggregation + +**Data Caching**: +- Cache OMDB responses to reduce API calls +- Store frequently accessed movies locally +- Background refresh for stale data + +### 4. Watchlists + +**Personal Watchlists**: +- "Want to Watch" queue +- "Watched" history with dates +- Custom personal lists + +**Club Watchlists**: +- Shared club watchlist (members can add/vote) +- "Currently Watching" - active selection +- "Club Favorites" - top-rated by members +- "Watch History" - completed films with dates + +**Voting System**: +- Upvote/downvote movies in club watchlist +- Nominate movies for club viewing +- Voting polls for next movie selection + +### 5. Ratings & Reviews + +**Rating System**: +- 1-5 star rating (half-stars allowed) +- Quick emoji reactions +- Detailed written reviews + +**Review Features**: +- Spoiler tagging +- Review likes/helpful votes +- Comment threads on reviews +- Edit/delete own reviews + +**Aggregation**: +- Club average rating per movie +- Personal rating history +- Rating trends over time + +### 6. Events & Watch Parties + +**Event Creation**: +- Schedule movie screenings (date, time, timezone) +- In-person vs. virtual events +- RSVP functionality +- Capacity limits for in-person events + +**Watch Party Features**: +- Countdown timer to event +- Streaming service links +- Live chat during screening (for virtual) +- Post-movie discussion threads + +**Notifications**: +- Event reminders (24h, 1h before) +- New movie added to watchlist +- Club activity updates +- Review responses + +### 7. Discussion Forums + +**Discussion Types**: +- Movie-specific discussions +- General club chat +- Theme discussions (Best Villains, Underrated Gems, etc.) +- Off-topic channel + +**Features**: +- Threaded replies +- Rich text formatting +- Image/GIF embedding +- Pin important discussions +- Archive old threads + +--- + +## Data Models + +### New Models + +``` +Club +β”œβ”€β”€ id: UUID (PK) +β”œβ”€β”€ name: str (unique per owner) +β”œβ”€β”€ slug: str (URL-friendly, unique) +β”œβ”€β”€ description: str +β”œβ”€β”€ cover_image_url: str | None +β”œβ”€β”€ theme: str | None +β”œβ”€β”€ visibility: enum (public, private, invite_only) +β”œβ”€β”€ owner_id: UUID (FK β†’ User) +β”œβ”€β”€ created_at: datetime +β”œβ”€β”€ updated_at: datetime +└── is_active: bool + +ClubMembership +β”œβ”€β”€ id: UUID (PK) +β”œβ”€β”€ club_id: UUID (FK β†’ Club) +β”œβ”€β”€ user_id: UUID (FK β†’ User) +β”œβ”€β”€ role: enum (owner, admin, member) +β”œβ”€β”€ status: enum (active, pending, banned) +β”œβ”€β”€ joined_at: datetime +└── invited_by_id: UUID | None (FK β†’ User) + +Movie (cached from OMDB) +β”œβ”€β”€ id: UUID (PK) +β”œβ”€β”€ imdb_id: str (unique, indexed) +β”œβ”€β”€ title: str +β”œβ”€β”€ year: str +β”œβ”€β”€ rated: str | None +β”œβ”€β”€ released: str | None +β”œβ”€β”€ runtime: str | None +β”œβ”€β”€ genre: str | None +β”œβ”€β”€ director: str | None +β”œβ”€β”€ writer: str | None +β”œβ”€β”€ actors: str | None +β”œβ”€β”€ plot: str | None +β”œβ”€β”€ language: str | None +β”œβ”€β”€ country: str | None +β”œβ”€β”€ awards: str | None +β”œβ”€β”€ poster_url: str | None +β”œβ”€β”€ imdb_rating: str | None +β”œβ”€β”€ imdb_votes: str | None +β”œβ”€β”€ box_office: str | None +β”œβ”€β”€ fetched_at: datetime +└── raw_data: JSON (full OMDB response) + +ClubWatchlist +β”œβ”€β”€ id: UUID (PK) +β”œβ”€β”€ club_id: UUID (FK β†’ Club) +β”œβ”€β”€ movie_id: UUID (FK β†’ Movie) +β”œβ”€β”€ added_by_id: UUID (FK β†’ User) +β”œβ”€β”€ status: enum (queued, watching, watched, removed) +β”œβ”€β”€ watch_date: datetime | None +β”œβ”€β”€ vote_score: int (default 0) +β”œβ”€β”€ added_at: datetime +└── updated_at: datetime + +WatchlistVote +β”œβ”€β”€ id: UUID (PK) +β”œβ”€β”€ watchlist_item_id: UUID (FK β†’ ClubWatchlist) +β”œβ”€β”€ user_id: UUID (FK β†’ User) +β”œβ”€β”€ vote: int (+1 or -1) +└── voted_at: datetime + +Rating +β”œβ”€β”€ id: UUID (PK) +β”œβ”€β”€ user_id: UUID (FK β†’ User) +β”œβ”€β”€ movie_id: UUID (FK β†’ Movie) +β”œβ”€β”€ club_id: UUID | None (FK β†’ Club, for club context) +β”œβ”€β”€ score: float (1.0 - 5.0) +β”œβ”€β”€ created_at: datetime +└── updated_at: datetime + +Review +β”œβ”€β”€ id: UUID (PK) +β”œβ”€β”€ user_id: UUID (FK β†’ User) +β”œβ”€β”€ movie_id: UUID (FK β†’ Movie) +β”œβ”€β”€ club_id: UUID | None (FK β†’ Club) +β”œβ”€β”€ rating_id: UUID | None (FK β†’ Rating) +β”œβ”€β”€ title: str | None +β”œβ”€β”€ content: str +β”œβ”€β”€ contains_spoilers: bool +β”œβ”€β”€ helpful_count: int +β”œβ”€β”€ created_at: datetime +└── updated_at: datetime + +Event +β”œβ”€β”€ id: UUID (PK) +β”œβ”€β”€ club_id: UUID (FK β†’ Club) +β”œβ”€β”€ movie_id: UUID | None (FK β†’ Movie) +β”œβ”€β”€ title: str +β”œβ”€β”€ description: str | None +β”œβ”€β”€ event_type: enum (in_person, virtual, hybrid) +β”œβ”€β”€ location: str | None +β”œβ”€β”€ streaming_link: str | None +β”œβ”€β”€ start_time: datetime (with timezone) +β”œβ”€β”€ end_time: datetime | None +β”œβ”€β”€ capacity: int | None +β”œβ”€β”€ created_by_id: UUID (FK β†’ User) +β”œβ”€β”€ created_at: datetime +└── is_cancelled: bool + +EventRSVP +β”œβ”€β”€ id: UUID (PK) +β”œβ”€β”€ event_id: UUID (FK β†’ Event) +β”œβ”€β”€ user_id: UUID (FK β†’ User) +β”œβ”€β”€ status: enum (attending, maybe, declined) +β”œβ”€β”€ responded_at: datetime +└── notes: str | None + +Discussion +β”œβ”€β”€ id: UUID (PK) +β”œβ”€β”€ club_id: UUID (FK β†’ Club) +β”œβ”€β”€ movie_id: UUID | None (FK β†’ Movie) +β”œβ”€β”€ author_id: UUID (FK β†’ User) +β”œβ”€β”€ title: str +β”œβ”€β”€ content: str +β”œβ”€β”€ is_pinned: bool +β”œβ”€β”€ is_locked: bool +β”œβ”€β”€ reply_count: int +β”œβ”€β”€ created_at: datetime +└── updated_at: datetime + +DiscussionReply +β”œβ”€β”€ id: UUID (PK) +β”œβ”€β”€ discussion_id: UUID (FK β†’ Discussion) +β”œβ”€β”€ author_id: UUID (FK β†’ User) +β”œβ”€β”€ parent_reply_id: UUID | None (FK β†’ self, for threading) +β”œβ”€β”€ content: str +β”œβ”€β”€ created_at: datetime +└── updated_at: datetime + +UserWatchlist (personal) +β”œβ”€β”€ id: UUID (PK) +β”œβ”€β”€ user_id: UUID (FK β†’ User) +β”œβ”€β”€ movie_id: UUID (FK β†’ Movie) +β”œβ”€β”€ status: enum (want_to_watch, watched) +β”œβ”€β”€ watched_at: datetime | None +β”œβ”€β”€ added_at: datetime +└── notes: str | None +``` + +### Relationships Summary + +``` +User (1) ──────< ClubMembership >────── (N) Club +User (1) ──────< Rating >────────────── (N) Movie +User (1) ──────< Review >────────────── (N) Movie +User (1) ──────< UserWatchlist >─────── (N) Movie +User (1) ──────< EventRSVP >─────────── (N) Event +User (1) ──────< Discussion +User (1) ──────< DiscussionReply + +Club (1) ──────< ClubWatchlist >─────── (N) Movie +Club (1) ──────< Event +Club (1) ──────< Discussion + +Movie (1) ─────< Rating +Movie (1) ─────< Review +Movie (1) ─────< ClubWatchlist +Movie (1) ─────< Event +Movie (1) ─────< Discussion +``` + +--- + +## API Design + +### New API Routes + +#### Movies (OMDB Integration) + +``` +GET /api/v1/movies/search?q={query}&year={year}&type={type} + β†’ Search OMDB, return paginated results + +GET /api/v1/movies/{imdb_id} + β†’ Get movie details (fetch from OMDB if not cached) + +GET /api/v1/movies/{imdb_id}/ratings + β†’ Get all ratings for a movie (filterable by club) + +GET /api/v1/movies/{imdb_id}/reviews + β†’ Get all reviews for a movie (paginated) +``` + +#### Clubs + +``` +POST /api/v1/clubs/ + β†’ Create new club + +GET /api/v1/clubs/ + β†’ List clubs (public + user's clubs) + +GET /api/v1/clubs/{slug} + β†’ Get club details + +PATCH /api/v1/clubs/{slug} + β†’ Update club (owner/admin only) + +DELETE /api/v1/clubs/{slug} + β†’ Delete club (owner only) + +GET /api/v1/clubs/{slug}/members + β†’ List club members + +POST /api/v1/clubs/{slug}/members + β†’ Add/invite member (admin+) + +PATCH /api/v1/clubs/{slug}/members/{user_id} + β†’ Update member role/status + +DELETE /api/v1/clubs/{slug}/members/{user_id} + β†’ Remove member + +POST /api/v1/clubs/{slug}/join + β†’ Request to join / join public club + +POST /api/v1/clubs/{slug}/leave + β†’ Leave club +``` + +#### Club Watchlist + +``` +GET /api/v1/clubs/{slug}/watchlist + β†’ Get club watchlist (filterable by status) + +POST /api/v1/clubs/{slug}/watchlist + β†’ Add movie to watchlist + +PATCH /api/v1/clubs/{slug}/watchlist/{item_id} + β†’ Update watchlist item status + +DELETE /api/v1/clubs/{slug}/watchlist/{item_id} + β†’ Remove from watchlist + +POST /api/v1/clubs/{slug}/watchlist/{item_id}/vote + β†’ Vote on watchlist item (+1/-1) +``` + +#### Ratings & Reviews + +``` +POST /api/v1/ratings/ + β†’ Create rating (movie_id, score, optional club_id) + +GET /api/v1/ratings/me + β†’ Get current user's ratings + +PATCH /api/v1/ratings/{id} + β†’ Update rating + +DELETE /api/v1/ratings/{id} + β†’ Delete rating + +POST /api/v1/reviews/ + β†’ Create review + +GET /api/v1/reviews/ + β†’ List reviews (filterable) + +PATCH /api/v1/reviews/{id} + β†’ Update review + +DELETE /api/v1/reviews/{id} + β†’ Delete review + +POST /api/v1/reviews/{id}/helpful + β†’ Mark review as helpful +``` + +#### Events + +``` +GET /api/v1/clubs/{slug}/events + β†’ List club events + +POST /api/v1/clubs/{slug}/events + β†’ Create event + +GET /api/v1/clubs/{slug}/events/{id} + β†’ Get event details + +PATCH /api/v1/clubs/{slug}/events/{id} + β†’ Update event + +DELETE /api/v1/clubs/{slug}/events/{id} + β†’ Cancel/delete event + +POST /api/v1/clubs/{slug}/events/{id}/rsvp + β†’ RSVP to event + +GET /api/v1/events/upcoming + β†’ Get user's upcoming events across all clubs +``` + +#### Discussions + +``` +GET /api/v1/clubs/{slug}/discussions + β†’ List club discussions + +POST /api/v1/clubs/{slug}/discussions + β†’ Create discussion + +GET /api/v1/clubs/{slug}/discussions/{id} + β†’ Get discussion with replies + +PATCH /api/v1/clubs/{slug}/discussions/{id} + β†’ Update discussion + +DELETE /api/v1/clubs/{slug}/discussions/{id} + β†’ Delete discussion + +POST /api/v1/clubs/{slug}/discussions/{id}/replies + β†’ Add reply + +PATCH /api/v1/discussions/replies/{id} + β†’ Update reply + +DELETE /api/v1/discussions/replies/{id} + β†’ Delete reply +``` + +#### Personal Watchlist + +``` +GET /api/v1/users/me/watchlist + β†’ Get personal watchlist + +POST /api/v1/users/me/watchlist + β†’ Add movie to personal watchlist + +PATCH /api/v1/users/me/watchlist/{id} + β†’ Update watchlist item + +DELETE /api/v1/users/me/watchlist/{id} + β†’ Remove from watchlist +``` + +--- + +## OMDB Integration + +### API Details + +- **Base URL**: `http://www.omdbapi.com/` +- **API Key**: Required (free tier: 1,000 daily requests) +- **Rate Limiting**: Implement client-side rate limiting + +### Integration Strategy + +1. **Search Proxy**: Backend proxies OMDB search requests +2. **Caching**: Store movie details in local `Movie` table +3. **Refresh Policy**: Re-fetch if `fetched_at` > 30 days +4. **Fallback**: Show cached data if OMDB is unavailable +5. **Poster Storage**: Consider CDN proxy for poster images + +### Environment Variables + +```env +OMDB_API_KEY=your_api_key_here +OMDB_CACHE_TTL_DAYS=30 +``` + +--- + +## Frontend Routes + +### New Routes + +``` +/ β†’ Landing page (public) / Dashboard (auth) +/clubs β†’ Browse/discover clubs +/clubs/new β†’ Create new club +/clubs/{slug} β†’ Club dashboard +/clubs/{slug}/members β†’ Club members list +/clubs/{slug}/watchlist β†’ Club watchlist +/clubs/{slug}/events β†’ Club events +/clubs/{slug}/events/{id} β†’ Event details +/clubs/{slug}/discussions β†’ Club discussions +/clubs/{slug}/discussions/{id} β†’ Discussion thread +/clubs/{slug}/settings β†’ Club settings (admin) + +/movies β†’ Movie search/browse +/movies/{imdb_id} β†’ Movie details page + +/watchlist β†’ Personal watchlist +/ratings β†’ Personal ratings history + +/settings β†’ User settings (existing) +/settings/profile β†’ Extended profile settings +``` + +--- + +## Component Architecture + +### New Components + +``` +components/ +β”œβ”€β”€ Movies/ +β”‚ β”œβ”€β”€ MovieCard.tsx # Poster card with quick actions +β”‚ β”œβ”€β”€ MovieDetails.tsx # Full movie info display +β”‚ β”œβ”€β”€ MovieSearch.tsx # Search interface +β”‚ β”œβ”€β”€ MoviePoster.tsx # Poster with fallback +β”‚ └── RatingDisplay.tsx # Star rating visualization +β”‚ +β”œβ”€β”€ Clubs/ +β”‚ β”œβ”€β”€ ClubCard.tsx # Club preview card +β”‚ β”œβ”€β”€ ClubHeader.tsx # Club page header +β”‚ β”œβ”€β”€ ClubSidebar.tsx # Club navigation +β”‚ β”œβ”€β”€ MemberList.tsx # Members grid/list +β”‚ β”œβ”€β”€ MemberCard.tsx # Individual member +β”‚ └── CreateClubForm.tsx # Club creation wizard +β”‚ +β”œβ”€β”€ Watchlist/ +β”‚ β”œβ”€β”€ WatchlistTable.tsx # Sortable watchlist +β”‚ β”œβ”€β”€ WatchlistCard.tsx # Movie in watchlist +β”‚ β”œβ”€β”€ VoteButtons.tsx # Upvote/downvote +β”‚ └── AddToWatchlist.tsx # Add movie modal +β”‚ +β”œβ”€β”€ Ratings/ +β”‚ β”œβ”€β”€ StarRating.tsx # Interactive stars +β”‚ β”œβ”€β”€ RatingForm.tsx # Rate movie form +β”‚ β”œβ”€β”€ ReviewCard.tsx # Review display +β”‚ └── ReviewForm.tsx # Write review form +β”‚ +β”œβ”€β”€ Events/ +β”‚ β”œβ”€β”€ EventCard.tsx # Event preview +β”‚ β”œβ”€β”€ EventDetails.tsx # Full event view +β”‚ β”œβ”€β”€ EventForm.tsx # Create/edit event +β”‚ β”œβ”€β”€ RSVPButtons.tsx # RSVP actions +β”‚ └── EventCalendar.tsx # Calendar view +β”‚ +β”œβ”€β”€ Discussions/ +β”‚ β”œβ”€β”€ DiscussionList.tsx # Discussion feed +β”‚ β”œβ”€β”€ DiscussionThread.tsx # Thread view +β”‚ β”œβ”€β”€ ReplyTree.tsx # Nested replies +β”‚ └── DiscussionForm.tsx # New discussion/reply +β”‚ +└── ArtDeco/ # Themed UI components + β”œβ”€β”€ DecoCard.tsx # Art deco styled card + β”œβ”€β”€ DecoButton.tsx # Styled button variants + β”œβ”€β”€ DecoBorder.tsx # Decorative borders + β”œβ”€β”€ DecoHeader.tsx # Page headers + └── FilmStrip.tsx # Decorative element +``` + +--- + +## Security Considerations + +### Club Access Control + +- Validate club membership on all club-specific endpoints +- Role-based permissions (owner > admin > member) +- Private club data only visible to members +- Invite links with expiration and usage limits + +### Rate Limiting + +- OMDB API: Max 1 request/second, 1000/day +- User actions: Prevent spam (votes, reviews) +- Search: Debounce and cache common queries + +### Data Privacy + +- User can control profile visibility +- Option to hide ratings/reviews from public +- GDPR-compliant data export/deletion + +--- + +## Performance Optimizations + +### Caching Strategy + +1. **OMDB Responses**: PostgreSQL with 30-day TTL +2. **Movie Posters**: Consider image CDN/proxy +3. **Club Data**: React Query with stale-while-revalidate +4. **Search Results**: Cache common searches + +### Database Indexes + +```sql +-- High-priority indexes +CREATE INDEX idx_movie_imdb_id ON movie(imdb_id); +CREATE INDEX idx_club_slug ON club(slug); +CREATE INDEX idx_membership_user_club ON club_membership(user_id, club_id); +CREATE INDEX idx_watchlist_club_status ON club_watchlist(club_id, status); +CREATE INDEX idx_rating_user_movie ON rating(user_id, movie_id); +CREATE INDEX idx_event_club_start ON event(club_id, start_time); +``` + +--- + +## Development Phases + +### Phase 1: Foundation (MVP) βœ… +- [x] OMDB integration (search, cache, display) +- [x] Movie details page +- [x] Personal watchlist (want to watch, watched) +- [x] Basic star ratings +- [x] Art Deco theme implementation + +### Phase 2: Clubs Core βœ… +- [x] Club CRUD operations +- [x] Club membership (join, leave, role management) +- [x] Club watchlist with voting +- [x] Basic club dashboard + +### Phase 3: Social Features +- [ ] Written reviews with spoiler tags +- [ ] Discussion forums +- [ ] Club activity feed +- [ ] Notifications (in-app) + +### Phase 4: Events +- [ ] Event creation and management +- [ ] RSVP system +- [ ] Event calendar view +- [ ] Event reminders (email) + +### Phase 5: Polish & Scale +- [ ] Advanced search filters +- [ ] Club discovery/recommendations +- [ ] User achievements/badges +- [ ] Mobile responsiveness refinement +- [ ] Performance optimization + +--- + +## Tech Stack Summary + +### Backend (Existing + Extended) +- **Framework**: FastAPI +- **ORM**: SQLModel +- **Database**: PostgreSQL +- **Auth**: JWT (existing) +- **External API**: OMDB (new) +- **Task Queue**: (Future) Celery for notifications + +### Frontend (Existing + Extended) +- **Framework**: React 19 + TypeScript +- **Routing**: TanStack Router +- **State**: TanStack Query +- **Styling**: Tailwind CSS + Custom Art Deco theme +- **Components**: shadcn/ui (customized) +- **Forms**: react-hook-form + Zod + +### Infrastructure (Existing) +- **Container**: Docker Compose +- **Proxy**: Traefik +- **Email**: SMTP (Mailcatcher for dev) + +--- + +## Success Metrics + +- User registration and retention rates +- Clubs created per week +- Movies added to watchlists +- Ratings and reviews submitted +- Event attendance rates +- Daily/weekly active users +- API response times (< 200ms p95) + +--- + +## Open Questions + +1. **Monetization**: Free tier limits? Premium features? +2. **Streaming Integration**: Link to where movies are available? +3. **Mobile App**: PWA sufficient or native apps needed? +4. **Moderation**: How to handle inappropriate content? +5. **Analytics**: What user behavior to track? + +--- + +*Document Version: 1.0* +*Last Updated: February 2026* +*Author: Vantage Development Team* diff --git a/backend/app/alembic/versions/a1b2c3d4e5f6_add_movie_watchlist_rating_models.py b/backend/app/alembic/versions/a1b2c3d4e5f6_add_movie_watchlist_rating_models.py new file mode 100644 index 0000000000..997c944ca1 --- /dev/null +++ b/backend/app/alembic/versions/a1b2c3d4e5f6_add_movie_watchlist_rating_models.py @@ -0,0 +1,98 @@ +"""Add movie watchlist rating models + +Revision ID: a1b2c3d4e5f6 +Revises: fe56fa70289e +Create Date: 2026-02-04 + +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "a1b2c3d4e5f6" +down_revision = "fe56fa70289e" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Movie table - OMDB cache + op.create_table( + "movie", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("imdb_id", sa.String(length=20), nullable=False), + sa.Column("title", sa.String(length=500), nullable=False), + sa.Column("year", sa.String(length=10), nullable=True), + sa.Column("rated", sa.String(length=20), nullable=True), + sa.Column("released", sa.String(length=50), nullable=True), + sa.Column("runtime", sa.String(length=20), nullable=True), + sa.Column("genre", sa.String(length=255), nullable=True), + sa.Column("director", sa.String(length=500), nullable=True), + sa.Column("writer", sa.String(length=1000), nullable=True), + sa.Column("actors", sa.String(length=1000), nullable=True), + sa.Column("plot", sa.Text(), nullable=True), + sa.Column("language", sa.String(length=255), nullable=True), + sa.Column("country", sa.String(length=255), nullable=True), + sa.Column("awards", sa.String(length=500), nullable=True), + sa.Column("poster_url", sa.String(length=1000), nullable=True), + sa.Column("imdb_rating", sa.String(length=10), nullable=True), + sa.Column("imdb_votes", sa.String(length=20), nullable=True), + sa.Column("box_office", sa.String(length=50), nullable=True), + sa.Column( + "fetched_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.Column("raw_data", postgresql.JSON(astext_type=sa.Text()), nullable=False, server_default="{}"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_movie_imdb_id", "movie", ["imdb_id"], unique=True) + + # UserWatchlist table + op.create_table( + "user_watchlist", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("user_id", sa.Uuid(), nullable=False), + sa.Column("movie_id", sa.Uuid(), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False, server_default="want_to_watch"), + sa.Column("notes", sa.String(length=1000), nullable=True), + sa.Column("watched_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "added_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.ForeignKeyConstraint(["movie_id"], ["movie.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_user_watchlist_user_id", "user_watchlist", ["user_id"]) + op.create_index( + "ix_user_watchlist_user_movie", "user_watchlist", ["user_id", "movie_id"], unique=True + ) + + # Rating table + op.create_table( + "rating", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("user_id", sa.Uuid(), nullable=False), + sa.Column("movie_id", sa.Uuid(), nullable=False), + sa.Column("club_id", sa.Uuid(), nullable=True), + sa.Column("score", sa.Float(), nullable=False), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.ForeignKeyConstraint(["movie_id"], ["movie.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_rating_user_id", "rating", ["user_id"]) + op.create_index("ix_rating_movie_id", "rating", ["movie_id"]) + op.create_index("ix_rating_user_movie", "rating", ["user_id", "movie_id"], unique=True) + + +def downgrade() -> None: + op.drop_table("rating") + op.drop_table("user_watchlist") + op.drop_table("movie") diff --git a/backend/app/alembic/versions/b2c3d4e5f6a7_add_club_models.py b/backend/app/alembic/versions/b2c3d4e5f6a7_add_club_models.py new file mode 100644 index 0000000000..2431145fc9 --- /dev/null +++ b/backend/app/alembic/versions/b2c3d4e5f6a7_add_club_models.py @@ -0,0 +1,121 @@ +"""Add club models + +Revision ID: b2c3d4e5f6a7 +Revises: a1b2c3d4e5f6 +Create Date: 2026-02-04 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "b2c3d4e5f6a7" +down_revision = "a1b2c3d4e5f6" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Club table + op.create_table( + "club", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("description", sa.String(length=1000), nullable=True), + sa.Column("cover_image_url", sa.String(length=1000), nullable=True), + sa.Column("visibility", sa.String(length=20), nullable=False, server_default="public"), + sa.Column("rules", sa.String(length=2000), nullable=True), + sa.Column("theme_color", sa.String(length=20), nullable=True), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_club_name", "club", ["name"]) + + # ClubMember table + op.create_table( + "club_member", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("club_id", sa.Uuid(), nullable=False), + sa.Column("user_id", sa.Uuid(), nullable=False), + sa.Column("role", sa.String(length=20), nullable=False, server_default="member"), + sa.Column( + "joined_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.ForeignKeyConstraint(["club_id"], ["club.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_club_member_club_id", "club_member", ["club_id"]) + op.create_index("ix_club_member_user_id", "club_member", ["user_id"]) + op.create_index( + "ix_club_member_club_user", "club_member", ["club_id", "user_id"], unique=True + ) + + # ClubWatchlist table + op.create_table( + "club_watchlist", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("club_id", sa.Uuid(), nullable=False), + sa.Column("movie_id", sa.Uuid(), nullable=False), + sa.Column("added_by_user_id", sa.Uuid(), nullable=False), + sa.Column("notes", sa.String(length=1000), nullable=True), + sa.Column( + "added_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.ForeignKeyConstraint(["club_id"], ["club.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["movie_id"], ["movie.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["added_by_user_id"], ["user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_club_watchlist_club_id", "club_watchlist", ["club_id"]) + op.create_index( + "ix_club_watchlist_club_movie", "club_watchlist", ["club_id", "movie_id"], unique=True + ) + + # ClubWatchlistVote table + op.create_table( + "club_watchlist_vote", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("watchlist_entry_id", sa.Uuid(), nullable=False), + sa.Column("user_id", sa.Uuid(), nullable=False), + sa.Column("vote_type", sa.String(length=20), nullable=False), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.ForeignKeyConstraint( + ["watchlist_entry_id"], ["club_watchlist.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_club_watchlist_vote_entry_id", "club_watchlist_vote", ["watchlist_entry_id"]) + op.create_index( + "ix_club_watchlist_vote_entry_user", + "club_watchlist_vote", + ["watchlist_entry_id", "user_id"], + unique=True, + ) + + # Add foreign key from rating to club + op.create_foreign_key( + "fk_rating_club_id", + "rating", + "club", + ["club_id"], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade() -> None: + op.drop_constraint("fk_rating_club_id", "rating", type_="foreignkey") + op.drop_table("club_watchlist_vote") + op.drop_table("club_watchlist") + op.drop_table("club_member") + op.drop_table("club") diff --git a/backend/app/api/main.py b/backend/app/api/main.py index eac18c8e8f..56dc424de2 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.routes import items, login, private, users, utils +from app.api.routes import clubs, items, login, movies, private, ratings, users, utils, watchlist from app.core.config import settings api_router = APIRouter() @@ -8,6 +8,10 @@ api_router.include_router(users.router) api_router.include_router(utils.router) api_router.include_router(items.router) +api_router.include_router(movies.router) +api_router.include_router(watchlist.router) +api_router.include_router(ratings.router) +api_router.include_router(clubs.router) if settings.ENVIRONMENT == "local": diff --git a/backend/app/api/routes/clubs.py b/backend/app/api/routes/clubs.py new file mode 100644 index 0000000000..d922135d37 --- /dev/null +++ b/backend/app/api/routes/clubs.py @@ -0,0 +1,637 @@ +"""Club routes for movie clubs feature""" + +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException, Query +from sqlmodel import col, func, or_, select +from sqlmodel.sql.expression import SelectOfScalar + +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + Club, + ClubCreate, + ClubMember, + ClubMemberPublic, + ClubMemberWithUser, + ClubMembersPublic, + ClubPublic, + ClubsPublic, + ClubUpdate, + ClubVisibility, + ClubWatchlist, + ClubWatchlistCreate, + ClubWatchlistPublic, + ClubWatchlistsPublic, + ClubWatchlistVote, + ClubWatchlistVotePublic, + ClubWatchlistWithMovie, + ClubWithMembers, + MemberRole, + Message, + Movie, + User, + VoteType, + get_datetime_utc, +) +from app.services.omdb import OMDBError, get_omdb_service + +router = APIRouter(prefix="/clubs", tags=["clubs"]) + + +# ============================================================================ +# HELPER FUNCTIONS +# ============================================================================ + + +def get_user_membership( + session: SessionDep, club_id: uuid.UUID, user_id: uuid.UUID +) -> ClubMember | None: + """Get user's membership in a club""" + return session.exec( + select(ClubMember).where( + ClubMember.club_id == club_id, ClubMember.user_id == user_id + ) + ).first() + + +def require_membership( + session: SessionDep, club_id: uuid.UUID, user_id: uuid.UUID +) -> ClubMember: + """Require user to be a member of the club""" + membership = get_user_membership(session, club_id, user_id) + if not membership or membership.role == MemberRole.PENDING: + raise HTTPException(status_code=403, detail="You are not a member of this club") + return membership + + +def require_admin( + session: SessionDep, club_id: uuid.UUID, user_id: uuid.UUID +) -> ClubMember: + """Require user to be admin or owner of the club""" + membership = get_user_membership(session, club_id, user_id) + if not membership or membership.role not in [MemberRole.OWNER, MemberRole.ADMIN]: + raise HTTPException(status_code=403, detail="Admin privileges required") + return membership + + +def require_owner( + session: SessionDep, club_id: uuid.UUID, user_id: uuid.UUID +) -> ClubMember: + """Require user to be owner of the club""" + membership = get_user_membership(session, club_id, user_id) + if not membership or membership.role != MemberRole.OWNER: + raise HTTPException(status_code=403, detail="Owner privileges required") + return membership + + +# ============================================================================ +# CLUB CRUD +# ============================================================================ + + +@router.get("/", response_model=ClubsPublic) +def list_clubs( + session: SessionDep, + current_user: CurrentUser, + skip: int = 0, + limit: int = 100, +) -> Any: + """ + List all public clubs and clubs the user is a member of. + """ + # Get IDs of clubs user is a member of + user_club_ids = session.exec( + select(ClubMember.club_id).where(ClubMember.user_id == current_user.id) + ).all() + + # Count + count_statement: SelectOfScalar[int] = ( + select(func.count()) + .select_from(Club) + .where( + or_( + Club.visibility == ClubVisibility.PUBLIC, + Club.id.in_(user_club_ids), # type: ignore + ) + ) + ) + count = session.exec(count_statement).one() + + # Fetch clubs + statement = ( + select(Club) + .where( + or_( + Club.visibility == ClubVisibility.PUBLIC, + Club.id.in_(user_club_ids), # type: ignore + ) + ) + .order_by(col(Club.created_at).desc()) + .offset(skip) + .limit(limit) + ) + clubs = session.exec(statement).all() + + return ClubsPublic(data=[ClubPublic.model_validate(c) for c in clubs], count=count) + + +@router.post("/", response_model=ClubPublic) +def create_club( + session: SessionDep, + current_user: CurrentUser, + club_in: ClubCreate, +) -> Any: + """ + Create a new club. The creator becomes the owner. + """ + club = Club.model_validate(club_in) + session.add(club) + session.commit() + session.refresh(club) + + # Add creator as owner + membership = ClubMember( + club_id=club.id, + user_id=current_user.id, + role=MemberRole.OWNER, + ) + session.add(membership) + session.commit() + + return club + + +@router.get("/{club_id}", response_model=ClubWithMembers) +def get_club( + session: SessionDep, + current_user: CurrentUser, + club_id: uuid.UUID, +) -> Any: + """ + Get club details with member list. + """ + club = session.get(Club, club_id) + if not club: + raise HTTPException(status_code=404, detail="Club not found") + + # Check access + membership = get_user_membership(session, club_id, current_user.id) + if club.visibility != ClubVisibility.PUBLIC and not membership: + raise HTTPException(status_code=403, detail="Access denied") + + # Get members with user details + members_query = select(ClubMember).where(ClubMember.club_id == club_id) + members = session.exec(members_query).all() + + members_with_users = [] + for member in members: + user = session.get(User, member.user_id) + if user: + members_with_users.append( + ClubMemberWithUser( + id=member.id, + club_id=member.club_id, + user_id=member.user_id, + role=member.role, + joined_at=member.joined_at, + user=user, # type: ignore + ) + ) + + return ClubWithMembers( + id=club.id, + name=club.name, + description=club.description, + cover_image_url=club.cover_image_url, + visibility=club.visibility, + rules=club.rules, + theme_color=club.theme_color, + created_at=club.created_at, + updated_at=club.updated_at, + members=members_with_users, + member_count=len(members_with_users), + ) + + +@router.patch("/{club_id}", response_model=ClubPublic) +def update_club( + session: SessionDep, + current_user: CurrentUser, + club_id: uuid.UUID, + club_in: ClubUpdate, +) -> Any: + """ + Update club details. Requires admin or owner. + """ + club = session.get(Club, club_id) + if not club: + raise HTTPException(status_code=404, detail="Club not found") + + require_admin(session, club_id, current_user.id) + + update_dict = club_in.model_dump(exclude_unset=True) + update_dict["updated_at"] = get_datetime_utc() + club.sqlmodel_update(update_dict) + session.add(club) + session.commit() + session.refresh(club) + + return club + + +@router.delete("/{club_id}") +def delete_club( + session: SessionDep, + current_user: CurrentUser, + club_id: uuid.UUID, +) -> Message: + """ + Delete a club. Requires owner. + """ + club = session.get(Club, club_id) + if not club: + raise HTTPException(status_code=404, detail="Club not found") + + require_owner(session, club_id, current_user.id) + + session.delete(club) + session.commit() + + return Message(message="Club deleted successfully") + + +# ============================================================================ +# MEMBERSHIP +# ============================================================================ + + +@router.post("/{club_id}/join", response_model=ClubMemberPublic) +def join_club( + session: SessionDep, + current_user: CurrentUser, + club_id: uuid.UUID, +) -> Any: + """ + Join a club. For invite-only clubs, creates pending membership. + """ + club = session.get(Club, club_id) + if not club: + raise HTTPException(status_code=404, detail="Club not found") + + # Check if already a member + existing = get_user_membership(session, club_id, current_user.id) + if existing: + raise HTTPException(status_code=400, detail="Already a member of this club") + + # Determine initial role based on visibility + if club.visibility == ClubVisibility.INVITE_ONLY: + role = MemberRole.PENDING + else: + role = MemberRole.MEMBER + + if club.visibility == ClubVisibility.PRIVATE: + raise HTTPException(status_code=403, detail="This club is private") + + membership = ClubMember( + club_id=club_id, + user_id=current_user.id, + role=role, + ) + session.add(membership) + session.commit() + session.refresh(membership) + + return membership + + +@router.delete("/{club_id}/leave") +def leave_club( + session: SessionDep, + current_user: CurrentUser, + club_id: uuid.UUID, +) -> Message: + """ + Leave a club. + """ + membership = get_user_membership(session, club_id, current_user.id) + if not membership: + raise HTTPException(status_code=404, detail="Not a member of this club") + + if membership.role == MemberRole.OWNER: + # Check if there are other admins to transfer ownership + other_admins = session.exec( + select(ClubMember).where( + ClubMember.club_id == club_id, + ClubMember.user_id != current_user.id, + ClubMember.role.in_([MemberRole.ADMIN, MemberRole.OWNER]), # type: ignore + ) + ).first() + if not other_admins: + raise HTTPException( + status_code=400, + detail="Cannot leave: you are the only owner. Transfer ownership first or delete the club.", + ) + + session.delete(membership) + session.commit() + + return Message(message="Left the club successfully") + + +@router.patch("/{club_id}/members/{user_id}", response_model=ClubMemberPublic) +def update_member_role( + session: SessionDep, + current_user: CurrentUser, + club_id: uuid.UUID, + user_id: uuid.UUID, + role: MemberRole = Query(...), +) -> Any: + """ + Update a member's role. Requires admin (for member changes) or owner (for admin changes). + """ + # Get target membership + membership = get_user_membership(session, club_id, user_id) + if not membership: + raise HTTPException(status_code=404, detail="Member not found") + + # Get current user's membership + current_membership = get_user_membership(session, club_id, current_user.id) + if not current_membership: + raise HTTPException(status_code=403, detail="Not a member of this club") + + # Permission checks + if role == MemberRole.OWNER: + require_owner(session, club_id, current_user.id) + # Transfer ownership: demote current owner to admin + current_membership.role = MemberRole.ADMIN + session.add(current_membership) + elif role == MemberRole.ADMIN or membership.role == MemberRole.ADMIN: + require_owner(session, club_id, current_user.id) + else: + require_admin(session, club_id, current_user.id) + + membership.role = role + session.add(membership) + session.commit() + session.refresh(membership) + + return membership + + +@router.delete("/{club_id}/members/{user_id}") +def remove_member( + session: SessionDep, + current_user: CurrentUser, + club_id: uuid.UUID, + user_id: uuid.UUID, +) -> Message: + """ + Remove a member from the club. Requires admin or owner. + """ + membership = get_user_membership(session, club_id, user_id) + if not membership: + raise HTTPException(status_code=404, detail="Member not found") + + # Cannot remove owner + if membership.role == MemberRole.OWNER: + raise HTTPException(status_code=400, detail="Cannot remove the owner") + + # Only owner can remove admins + if membership.role == MemberRole.ADMIN: + require_owner(session, club_id, current_user.id) + else: + require_admin(session, club_id, current_user.id) + + session.delete(membership) + session.commit() + + return Message(message="Member removed successfully") + + +# ============================================================================ +# CLUB WATCHLIST +# ============================================================================ + + +@router.get("/{club_id}/watchlist", response_model=ClubWatchlistsPublic) +def get_club_watchlist( + session: SessionDep, + current_user: CurrentUser, + club_id: uuid.UUID, + skip: int = 0, + limit: int = 100, +) -> Any: + """ + Get club's watchlist with movies and vote counts. + """ + club = session.get(Club, club_id) + if not club: + raise HTTPException(status_code=404, detail="Club not found") + + require_membership(session, club_id, current_user.id) + + # Count + count_statement: SelectOfScalar[int] = ( + select(func.count()) + .select_from(ClubWatchlist) + .where(ClubWatchlist.club_id == club_id) + ) + count = session.exec(count_statement).one() + + # Fetch entries + statement = ( + select(ClubWatchlist) + .where(ClubWatchlist.club_id == club_id) + .order_by(col(ClubWatchlist.added_at).desc()) + .offset(skip) + .limit(limit) + ) + entries = session.exec(statement).all() + + results: list[ClubWatchlistWithMovie] = [] + for entry in entries: + movie = session.get(Movie, entry.movie_id) + if movie: + # Get vote counts + upvotes = session.exec( + select(func.count()) + .select_from(ClubWatchlistVote) + .where( + ClubWatchlistVote.watchlist_entry_id == entry.id, + ClubWatchlistVote.vote_type == VoteType.UPVOTE, + ) + ).one() + downvotes = session.exec( + select(func.count()) + .select_from(ClubWatchlistVote) + .where( + ClubWatchlistVote.watchlist_entry_id == entry.id, + ClubWatchlistVote.vote_type == VoteType.DOWNVOTE, + ) + ).one() + + # Get user's vote + user_vote_obj = session.exec( + select(ClubWatchlistVote).where( + ClubWatchlistVote.watchlist_entry_id == entry.id, + ClubWatchlistVote.user_id == current_user.id, + ) + ).first() + user_vote = user_vote_obj.vote_type if user_vote_obj else None + + results.append( + ClubWatchlistWithMovie( + id=entry.id, + club_id=entry.club_id, + movie_id=entry.movie_id, + added_by_user_id=entry.added_by_user_id, + notes=entry.notes, + added_at=entry.added_at, + movie=movie, # type: ignore + upvotes=upvotes, + downvotes=downvotes, + user_vote=user_vote, + ) + ) + + return ClubWatchlistsPublic(data=results, count=count) + + +@router.post("/{club_id}/watchlist", response_model=ClubWatchlistPublic) +async def add_to_club_watchlist( + session: SessionDep, + current_user: CurrentUser, + club_id: uuid.UUID, + watchlist_in: ClubWatchlistCreate, +) -> Any: + """ + Add a movie to the club's watchlist. + """ + club = session.get(Club, club_id) + if not club: + raise HTTPException(status_code=404, detail="Club not found") + + require_membership(session, club_id, current_user.id) + + # Get or fetch movie + try: + omdb = get_omdb_service() + movie = await omdb.get_or_fetch_movie( + session=session, + imdb_id=watchlist_in.movie_imdb_id, + ) + except OMDBError as e: + raise HTTPException(status_code=404, detail=f"Movie not found: {e}") + + # Check if already in watchlist + existing = session.exec( + select(ClubWatchlist).where( + ClubWatchlist.club_id == club_id, + ClubWatchlist.movie_id == movie.id, + ) + ).first() + + if existing: + raise HTTPException( + status_code=400, + detail="Movie already in club watchlist", + ) + + entry = ClubWatchlist( + club_id=club_id, + movie_id=movie.id, + added_by_user_id=current_user.id, + notes=watchlist_in.notes, + ) + session.add(entry) + session.commit() + session.refresh(entry) + + return entry + + +@router.delete("/{club_id}/watchlist/{entry_id}") +def remove_from_club_watchlist( + session: SessionDep, + current_user: CurrentUser, + club_id: uuid.UUID, + entry_id: uuid.UUID, +) -> Message: + """ + Remove a movie from the club's watchlist. + Requires admin/owner or being the user who added it. + """ + entry = session.get(ClubWatchlist, entry_id) + if not entry or entry.club_id != club_id: + raise HTTPException(status_code=404, detail="Watchlist entry not found") + + membership = require_membership(session, club_id, current_user.id) + + # Allow removal if user added it or is admin/owner + if entry.added_by_user_id != current_user.id: + if membership.role not in [MemberRole.OWNER, MemberRole.ADMIN]: + raise HTTPException(status_code=403, detail="Cannot remove this entry") + + session.delete(entry) + session.commit() + + return Message(message="Removed from club watchlist") + + +# ============================================================================ +# VOTING +# ============================================================================ + + +@router.post("/{club_id}/watchlist/{entry_id}/vote", response_model=ClubWatchlistVotePublic) +def vote_on_watchlist_entry( + session: SessionDep, + current_user: CurrentUser, + club_id: uuid.UUID, + entry_id: uuid.UUID, + vote_type: VoteType = Query(...), +) -> Any: + """ + Vote on a watchlist entry. Toggle if voting same type again. + """ + entry = session.get(ClubWatchlist, entry_id) + if not entry or entry.club_id != club_id: + raise HTTPException(status_code=404, detail="Watchlist entry not found") + + require_membership(session, club_id, current_user.id) + + # Check for existing vote + existing_vote = session.exec( + select(ClubWatchlistVote).where( + ClubWatchlistVote.watchlist_entry_id == entry_id, + ClubWatchlistVote.user_id == current_user.id, + ) + ).first() + + if existing_vote: + if existing_vote.vote_type == vote_type: + # Toggle off - remove vote + session.delete(existing_vote) + session.commit() + return Message(message="Vote removed") # type: ignore + else: + # Change vote type + existing_vote.vote_type = vote_type + existing_vote.created_at = get_datetime_utc() + session.add(existing_vote) + session.commit() + session.refresh(existing_vote) + return existing_vote + + # Create new vote + vote = ClubWatchlistVote( + watchlist_entry_id=entry_id, + user_id=current_user.id, + vote_type=vote_type, + ) + session.add(vote) + session.commit() + session.refresh(vote) + + return vote diff --git a/backend/app/api/routes/movies.py b/backend/app/api/routes/movies.py new file mode 100644 index 0000000000..928ce81843 --- /dev/null +++ b/backend/app/api/routes/movies.py @@ -0,0 +1,98 @@ +"""Movie search and details routes (OMDB integration)""" + +from typing import Any + +from fastapi import APIRouter, HTTPException, Query +from sqlmodel import func, select + +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + Movie, + MoviePublic, + MovieRatingStats, + MovieSearchPublic, + Rating, +) +from app.services.omdb import OMDBError, get_omdb_service + +router = APIRouter(prefix="/movies", tags=["movies"]) + + +@router.get("/search", response_model=MovieSearchPublic) +async def search_movies( + session: SessionDep, + current_user: CurrentUser, + q: str = Query(..., min_length=1, description="Search query"), + year: str | None = Query(None, description="Filter by year"), + type: str | None = Query(None, description="Filter by type: movie, series, episode"), + page: int = Query(1, ge=1, description="Page number"), +) -> Any: + """ + Search movies via OMDB API. + Results are not cached (search results change frequently). + """ + try: + omdb = get_omdb_service() + results, total = await omdb.search_movies( + query=q, + year=year, + type=type, + page=page, + ) + return MovieSearchPublic(data=results, total_results=total) + except OMDBError as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@router.get("/{imdb_id}", response_model=MoviePublic) +async def get_movie( + session: SessionDep, + current_user: CurrentUser, + imdb_id: str, + refresh: bool = Query(False, description="Force refresh from OMDB"), +) -> Any: + """ + Get movie details by IMDB ID. + Fetches from OMDB and caches if not already cached or stale. + """ + try: + omdb = get_omdb_service() + movie = await omdb.get_or_fetch_movie( + session=session, + imdb_id=imdb_id, + force_refresh=refresh, + ) + return movie + except OMDBError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@router.get("/{imdb_id}/ratings", response_model=MovieRatingStats) +def get_movie_ratings( + session: SessionDep, + current_user: CurrentUser, + imdb_id: str, +) -> Any: + """ + Get aggregated rating stats for a movie. + """ + # First ensure movie exists in cache + statement = select(Movie).where(Movie.imdb_id == imdb_id) + movie = session.exec(statement).first() + + if not movie: + raise HTTPException(status_code=404, detail="Movie not found") + + # Get rating stats + stats_statement = select( + func.avg(Rating.score).label("average_rating"), + func.count(Rating.id).label("rating_count"), + ).where(Rating.movie_id == movie.id) + + result = session.exec(stats_statement).one() + + return MovieRatingStats( + movie_id=movie.id, + average_rating=float(result.average_rating or 0), + rating_count=result.rating_count or 0, + ) diff --git a/backend/app/api/routes/ratings.py b/backend/app/api/routes/ratings.py new file mode 100644 index 0000000000..8ab89e5aed --- /dev/null +++ b/backend/app/api/routes/ratings.py @@ -0,0 +1,174 @@ +"""Movie ratings routes""" + +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException +from sqlmodel import col, func, select +from sqlmodel.sql.expression import SelectOfScalar + +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + Message, + Movie, + Rating, + RatingCreate, + RatingPublic, + RatingsPublic, + RatingUpdate, + RatingWithMovie, + get_datetime_utc, +) +from app.services.omdb import OMDBError, get_omdb_service + +router = APIRouter(prefix="/ratings", tags=["ratings"]) + + +@router.post("/", response_model=RatingPublic) +async def create_rating( + session: SessionDep, + current_user: CurrentUser, + rating_in: RatingCreate, +) -> Any: + """ + Create or update a rating for a movie. + Only one rating per user per movie is allowed (upsert behavior). + """ + # Get or fetch movie + try: + omdb = get_omdb_service() + movie = await omdb.get_or_fetch_movie( + session=session, + imdb_id=rating_in.movie_imdb_id, + ) + except OMDBError as e: + raise HTTPException(status_code=404, detail=f"Movie not found: {e}") + + # Check if rating already exists + existing = session.exec( + select(Rating).where( + Rating.user_id == current_user.id, + Rating.movie_id == movie.id, + ) + ).first() + + if existing: + # Update existing rating + existing.score = rating_in.score + existing.updated_at = get_datetime_utc() + session.add(existing) + session.commit() + session.refresh(existing) + return existing + + # Create new rating + rating = Rating( + user_id=current_user.id, + movie_id=movie.id, + club_id=rating_in.club_id, + score=rating_in.score, + ) + session.add(rating) + session.commit() + session.refresh(rating) + + return rating + + +@router.get("/me", response_model=RatingsPublic) +def get_my_ratings( + session: SessionDep, + current_user: CurrentUser, + skip: int = 0, + limit: int = 100, +) -> Any: + """ + Get current user's ratings with movie details. + """ + # Count query + count_statement: SelectOfScalar[int] = ( + select(func.count()) + .select_from(Rating) + .where(Rating.user_id == current_user.id) + ) + count = session.exec(count_statement).one() + + # Fetch ratings + statement = ( + select(Rating) + .where(Rating.user_id == current_user.id) + .order_by(col(Rating.updated_at).desc()) + .offset(skip) + .limit(limit) + ) + ratings = session.exec(statement).all() + + # Manually load movies for each rating + results: list[RatingWithMovie] = [] + for rating in ratings: + movie = session.get(Movie, rating.movie_id) + if movie: + results.append( + RatingWithMovie( + id=rating.id, + score=rating.score, + movie_id=rating.movie_id, + created_at=rating.created_at, + updated_at=rating.updated_at, + movie=movie, # type: ignore + ) + ) + + return RatingsPublic(data=results, count=count) + + +@router.patch("/{rating_id}", response_model=RatingPublic) +def update_rating( + session: SessionDep, + current_user: CurrentUser, + rating_id: uuid.UUID, + rating_in: RatingUpdate, +) -> Any: + """ + Update a rating score. + """ + rating = session.get(Rating, rating_id) + + if not rating: + raise HTTPException(status_code=404, detail="Rating not found") + + if rating.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + if rating_in.score is not None: + rating.score = rating_in.score + rating.updated_at = get_datetime_utc() + + session.add(rating) + session.commit() + session.refresh(rating) + + return rating + + +@router.delete("/{rating_id}") +def delete_rating( + session: SessionDep, + current_user: CurrentUser, + rating_id: uuid.UUID, +) -> Message: + """ + Delete a rating. + """ + rating = session.get(Rating, rating_id) + + if not rating: + raise HTTPException(status_code=404, detail="Rating not found") + + if rating.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + session.delete(rating) + session.commit() + + return Message(message="Rating deleted successfully") diff --git a/backend/app/api/routes/watchlist.py b/backend/app/api/routes/watchlist.py new file mode 100644 index 0000000000..77e610613b --- /dev/null +++ b/backend/app/api/routes/watchlist.py @@ -0,0 +1,189 @@ +"""Personal watchlist routes""" + +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException, Query +from sqlmodel import col, func, select +from sqlmodel.sql.expression import SelectOfScalar + +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + Message, + Movie, + UserWatchlist, + UserWatchlistCreate, + UserWatchlistPublic, + UserWatchlistsPublic, + UserWatchlistUpdate, + UserWatchlistWithMovie, + WatchlistStatus, + get_datetime_utc, +) +from app.services.omdb import OMDBError, get_omdb_service + +router = APIRouter(prefix="/users/me/watchlist", tags=["watchlist"]) + + +@router.get("/", response_model=UserWatchlistsPublic) +def get_my_watchlist( + session: SessionDep, + current_user: CurrentUser, + status: WatchlistStatus | None = Query(None, description="Filter by status"), + skip: int = 0, + limit: int = 100, +) -> Any: + """ + Get current user's watchlist with movie details. + """ + # Base query + base_conditions = [UserWatchlist.user_id == current_user.id] + if status: + base_conditions.append(UserWatchlist.status == status) + + # Count query + count_statement: SelectOfScalar[int] = ( + select(func.count()) + .select_from(UserWatchlist) + .where(*base_conditions) + ) + count = session.exec(count_statement).one() + + # Fetch entries with movies + statement = ( + select(UserWatchlist) + .where(*base_conditions) + .order_by(col(UserWatchlist.added_at).desc()) + .offset(skip) + .limit(limit) + ) + entries = session.exec(statement).all() + + # Manually load movies for each entry + results: list[UserWatchlistWithMovie] = [] + for entry in entries: + movie = session.get(Movie, entry.movie_id) + if movie: + results.append( + UserWatchlistWithMovie( + id=entry.id, + status=entry.status, + notes=entry.notes, + movie_id=entry.movie_id, + watched_at=entry.watched_at, + added_at=entry.added_at, + movie=movie, # type: ignore + ) + ) + + return UserWatchlistsPublic(data=results, count=count) + + +@router.post("/", response_model=UserWatchlistPublic) +async def add_to_watchlist( + session: SessionDep, + current_user: CurrentUser, + watchlist_in: UserWatchlistCreate, +) -> Any: + """ + Add a movie to personal watchlist. + Movie will be fetched and cached from OMDB if not already cached. + """ + # Get or fetch movie + try: + omdb = get_omdb_service() + movie = await omdb.get_or_fetch_movie( + session=session, + imdb_id=watchlist_in.movie_imdb_id, + ) + except OMDBError as e: + raise HTTPException(status_code=404, detail=f"Movie not found: {e}") + + # Check if already in watchlist + existing = session.exec( + select(UserWatchlist).where( + UserWatchlist.user_id == current_user.id, + UserWatchlist.movie_id == movie.id, + ) + ).first() + + if existing: + raise HTTPException( + status_code=400, + detail="Movie already in watchlist", + ) + + # Create watchlist entry + entry = UserWatchlist( + user_id=current_user.id, + movie_id=movie.id, + status=watchlist_in.status, + notes=watchlist_in.notes, + watched_at=get_datetime_utc() + if watchlist_in.status == WatchlistStatus.WATCHED + else None, + ) + session.add(entry) + session.commit() + session.refresh(entry) + + return entry + + +@router.patch("/{watchlist_id}", response_model=UserWatchlistPublic) +def update_watchlist_entry( + session: SessionDep, + current_user: CurrentUser, + watchlist_id: uuid.UUID, + watchlist_in: UserWatchlistUpdate, +) -> Any: + """ + Update a watchlist entry (status, notes, watched_at). + """ + entry = session.get(UserWatchlist, watchlist_id) + + if not entry: + raise HTTPException(status_code=404, detail="Watchlist entry not found") + + if entry.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + update_dict = watchlist_in.model_dump(exclude_unset=True) + + # Auto-set watched_at when status changes to watched + if ( + watchlist_in.status == WatchlistStatus.WATCHED + and entry.status != WatchlistStatus.WATCHED + and "watched_at" not in update_dict + ): + update_dict["watched_at"] = get_datetime_utc() + + entry.sqlmodel_update(update_dict) + session.add(entry) + session.commit() + session.refresh(entry) + + return entry + + +@router.delete("/{watchlist_id}") +def remove_from_watchlist( + session: SessionDep, + current_user: CurrentUser, + watchlist_id: uuid.UUID, +) -> Message: + """ + Remove a movie from personal watchlist. + """ + entry = session.get(UserWatchlist, watchlist_id) + + if not entry: + raise HTTPException(status_code=404, detail="Watchlist entry not found") + + if entry.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + session.delete(entry) + session.commit() + + return Message(message="Removed from watchlist successfully") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 650b9f7910..3b3ddf9168 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -50,6 +50,12 @@ def all_cors_origins(self) -> list[str]: PROJECT_NAME: str SENTRY_DSN: HttpUrl | None = None + + # OMDB API Configuration + OMDB_API_KEY: str | None = None + OMDB_BASE_URL: str = "http://www.omdbapi.com/" + OMDB_CACHE_TTL_DAYS: int = 30 + POSTGRES_SERVER: str POSTGRES_PORT: int = 5432 POSTGRES_USER: str diff --git a/backend/app/models.py b/backend/app/models.py index b5132e0e2c..3172606d70 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,11 +1,36 @@ +import enum import uuid from datetime import datetime, timezone from pydantic import EmailStr -from sqlalchemy import DateTime +from sqlalchemy import Column, DateTime, Text +from sqlalchemy.dialects.postgresql import JSON from sqlmodel import Field, Relationship, SQLModel +class WatchlistStatus(str, enum.Enum): + WANT_TO_WATCH = "want_to_watch" + WATCHED = "watched" + + +class ClubVisibility(str, enum.Enum): + PUBLIC = "public" + PRIVATE = "private" + INVITE_ONLY = "invite_only" + + +class MemberRole(str, enum.Enum): + OWNER = "owner" + ADMIN = "admin" + MEMBER = "member" + PENDING = "pending" + + +class VoteType(str, enum.Enum): + UPVOTE = "upvote" + DOWNVOTE = "downvote" + + def get_datetime_utc() -> datetime: return datetime.now(timezone.utc) @@ -54,6 +79,13 @@ class User(UserBase, table=True): sa_type=DateTime(timezone=True), # type: ignore ) items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True) + watchlist_entries: list["UserWatchlist"] = Relationship( + back_populates="user", cascade_delete=True + ) + ratings: list["Rating"] = Relationship(back_populates="user", cascade_delete=True) + club_memberships: list["ClubMember"] = Relationship( + back_populates="user", cascade_delete=True + ) # Properties to return via API, id is always required @@ -108,6 +140,416 @@ class ItemsPublic(SQLModel): count: int +# ============================================================================ +# MOVIE MODELS (OMDB Cache) +# ============================================================================ + + +class MovieBase(SQLModel): + imdb_id: str = Field(max_length=20) + title: str = Field(max_length=500) + year: str | None = Field(default=None, max_length=10) + rated: str | None = Field(default=None, max_length=20) + released: str | None = Field(default=None, max_length=50) + runtime: str | None = Field(default=None, max_length=20) + genre: str | None = Field(default=None, max_length=255) + director: str | None = Field(default=None, max_length=500) + writer: str | None = Field(default=None, max_length=1000) + actors: str | None = Field(default=None, max_length=1000) + plot: str | None = Field(default=None, sa_column=Column(Text)) + language: str | None = Field(default=None, max_length=255) + country: str | None = Field(default=None, max_length=255) + awards: str | None = Field(default=None, max_length=500) + poster_url: str | None = Field(default=None, max_length=1000) + imdb_rating: str | None = Field(default=None, max_length=10) + imdb_votes: str | None = Field(default=None, max_length=20) + box_office: str | None = Field(default=None, max_length=50) + + +class Movie(MovieBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + imdb_id: str = Field(unique=True, index=True, max_length=20) + fetched_at: datetime = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore + ) + raw_data: dict = Field(default={}, sa_column=Column(JSON)) + + # Relationships + watchlist_entries: list["UserWatchlist"] = Relationship( + back_populates="movie", cascade_delete=True + ) + ratings: list["Rating"] = Relationship(back_populates="movie", cascade_delete=True) + club_watchlist_entries: list["ClubWatchlist"] = Relationship( + back_populates="movie", cascade_delete=True + ) + + +class MoviePublic(MovieBase): + id: uuid.UUID + fetched_at: datetime + + +class MoviesPublic(SQLModel): + data: list[MoviePublic] + count: int + + +class MovieSearchResult(SQLModel): + """Lightweight movie result from OMDB search""" + + imdb_id: str + title: str + year: str | None = None + poster_url: str | None = None + type: str | None = None + + +class MovieSearchPublic(SQLModel): + data: list[MovieSearchResult] + total_results: int + + +class MovieRatingStats(SQLModel): + """Aggregated rating statistics for a movie""" + + movie_id: uuid.UUID + average_rating: float + rating_count: int + + +# ============================================================================ +# USER WATCHLIST MODELS +# ============================================================================ + + +class UserWatchlistBase(SQLModel): + status: WatchlistStatus = WatchlistStatus.WANT_TO_WATCH + notes: str | None = Field(default=None, max_length=1000) + + +class UserWatchlistCreate(SQLModel): + movie_imdb_id: str = Field(max_length=20) + status: WatchlistStatus = WatchlistStatus.WANT_TO_WATCH + notes: str | None = Field(default=None, max_length=1000) + + +class UserWatchlistUpdate(SQLModel): + status: WatchlistStatus | None = None + notes: str | None = Field(default=None, max_length=1000) + watched_at: datetime | None = None + + +class UserWatchlist(UserWatchlistBase, table=True): + __tablename__ = "user_watchlist" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + user_id: uuid.UUID = Field(foreign_key="user.id", nullable=False, ondelete="CASCADE") + movie_id: uuid.UUID = Field( + foreign_key="movie.id", nullable=False, ondelete="CASCADE" + ) + watched_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + added_at: datetime = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore + ) + + # Relationships + user: User | None = Relationship(back_populates="watchlist_entries") + movie: Movie | None = Relationship(back_populates="watchlist_entries") + + +class UserWatchlistPublic(UserWatchlistBase): + id: uuid.UUID + movie_id: uuid.UUID + watched_at: datetime | None = None + added_at: datetime + + +class UserWatchlistWithMovie(UserWatchlistPublic): + """Watchlist entry with full movie details""" + + movie: MoviePublic + + +class UserWatchlistsPublic(SQLModel): + data: list[UserWatchlistWithMovie] + count: int + + +# ============================================================================ +# RATING MODELS +# ============================================================================ + + +class RatingBase(SQLModel): + score: float = Field(ge=1.0, le=5.0) + + +class RatingCreate(SQLModel): + movie_imdb_id: str = Field(max_length=20) + score: float = Field(ge=1.0, le=5.0) + club_id: uuid.UUID | None = None # For future club context + + +class RatingUpdate(SQLModel): + score: float | None = Field(default=None, ge=1.0, le=5.0) + + +class Rating(RatingBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + user_id: uuid.UUID = Field(foreign_key="user.id", nullable=False, ondelete="CASCADE") + movie_id: uuid.UUID = Field( + foreign_key="movie.id", nullable=False, ondelete="CASCADE" + ) + club_id: uuid.UUID | None = Field( + default=None, foreign_key="club.id", ondelete="SET NULL" + ) + created_at: datetime = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore + ) + updated_at: datetime = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore + ) + + # Relationships + user: User | None = Relationship(back_populates="ratings") + movie: Movie | None = Relationship(back_populates="ratings") + + +class RatingPublic(RatingBase): + id: uuid.UUID + movie_id: uuid.UUID + created_at: datetime + updated_at: datetime + + +class RatingWithMovie(RatingPublic): + """Rating with full movie details""" + + movie: MoviePublic + + +class RatingsPublic(SQLModel): + data: list[RatingWithMovie] + count: int + + +# ============================================================================ +# CLUB MODELS +# ============================================================================ + + +class ClubBase(SQLModel): + name: str = Field(min_length=1, max_length=100) + description: str | None = Field(default=None, max_length=1000) + cover_image_url: str | None = Field(default=None, max_length=1000) + visibility: ClubVisibility = ClubVisibility.PUBLIC + rules: str | None = Field(default=None, max_length=2000) + theme_color: str | None = Field(default=None, max_length=20) + + +class ClubCreate(SQLModel): + name: str = Field(min_length=1, max_length=100) + description: str | None = Field(default=None, max_length=1000) + visibility: ClubVisibility = ClubVisibility.PUBLIC + rules: str | None = Field(default=None, max_length=2000) + theme_color: str | None = Field(default=None, max_length=20) + + +class ClubUpdate(SQLModel): + name: str | None = Field(default=None, min_length=1, max_length=100) + description: str | None = Field(default=None, max_length=1000) + cover_image_url: str | None = Field(default=None, max_length=1000) + visibility: ClubVisibility | None = None + rules: str | None = Field(default=None, max_length=2000) + theme_color: str | None = Field(default=None, max_length=20) + + +class Club(ClubBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + created_at: datetime = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore + ) + updated_at: datetime = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore + ) + + # Relationships + members: list["ClubMember"] = Relationship( + back_populates="club", cascade_delete=True + ) + watchlist_entries: list["ClubWatchlist"] = Relationship( + back_populates="club", cascade_delete=True + ) + ratings: list["Rating"] = Relationship( + sa_relationship_kwargs={"foreign_keys": "[Rating.club_id]"} + ) + + +class ClubPublic(ClubBase): + id: uuid.UUID + created_at: datetime + updated_at: datetime + + +class ClubsPublic(SQLModel): + data: list[ClubPublic] + count: int + + +# ============================================================================ +# CLUB MEMBER MODELS +# ============================================================================ + + +class ClubMemberBase(SQLModel): + role: MemberRole = MemberRole.MEMBER + + +class ClubMember(ClubMemberBase, table=True): + __tablename__ = "club_member" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + club_id: uuid.UUID = Field( + foreign_key="club.id", nullable=False, ondelete="CASCADE" + ) + user_id: uuid.UUID = Field( + foreign_key="user.id", nullable=False, ondelete="CASCADE" + ) + joined_at: datetime = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore + ) + + # Relationships + club: Club | None = Relationship(back_populates="members") + user: User | None = Relationship(back_populates="club_memberships") + + +class ClubMemberPublic(ClubMemberBase): + id: uuid.UUID + club_id: uuid.UUID + user_id: uuid.UUID + joined_at: datetime + + +class ClubMemberWithUser(ClubMemberPublic): + """Club member with user details""" + user: UserPublic + + +class ClubMembersPublic(SQLModel): + data: list[ClubMemberWithUser] + count: int + + +class ClubWithMembers(ClubPublic): + """Club with member list""" + members: list[ClubMemberWithUser] + member_count: int + + +# ============================================================================ +# CLUB WATCHLIST MODELS +# ============================================================================ + + +class ClubWatchlistBase(SQLModel): + notes: str | None = Field(default=None, max_length=1000) + + +class ClubWatchlistCreate(SQLModel): + movie_imdb_id: str = Field(max_length=20) + notes: str | None = Field(default=None, max_length=1000) + + +class ClubWatchlist(ClubWatchlistBase, table=True): + __tablename__ = "club_watchlist" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + club_id: uuid.UUID = Field( + foreign_key="club.id", nullable=False, ondelete="CASCADE" + ) + movie_id: uuid.UUID = Field( + foreign_key="movie.id", nullable=False, ondelete="CASCADE" + ) + added_by_user_id: uuid.UUID = Field( + foreign_key="user.id", nullable=False, ondelete="CASCADE" + ) + added_at: datetime = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore + ) + + # Relationships + club: Club | None = Relationship(back_populates="watchlist_entries") + movie: Movie | None = Relationship(back_populates="club_watchlist_entries") + votes: list["ClubWatchlistVote"] = Relationship( + back_populates="watchlist_entry", cascade_delete=True + ) + + +class ClubWatchlistPublic(ClubWatchlistBase): + id: uuid.UUID + club_id: uuid.UUID + movie_id: uuid.UUID + added_by_user_id: uuid.UUID + added_at: datetime + + +class ClubWatchlistWithMovie(ClubWatchlistPublic): + """Club watchlist entry with movie details and vote counts""" + movie: MoviePublic + upvotes: int = 0 + downvotes: int = 0 + user_vote: VoteType | None = None + + +class ClubWatchlistsPublic(SQLModel): + data: list[ClubWatchlistWithMovie] + count: int + + +# ============================================================================ +# CLUB WATCHLIST VOTE MODELS +# ============================================================================ + + +class ClubWatchlistVoteBase(SQLModel): + vote_type: VoteType + + +class ClubWatchlistVote(ClubWatchlistVoteBase, table=True): + __tablename__ = "club_watchlist_vote" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + watchlist_entry_id: uuid.UUID = Field( + foreign_key="club_watchlist.id", nullable=False, ondelete="CASCADE" + ) + user_id: uuid.UUID = Field( + foreign_key="user.id", nullable=False, ondelete="CASCADE" + ) + created_at: datetime = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore + ) + + # Relationships + watchlist_entry: ClubWatchlist | None = Relationship(back_populates="votes") + + +class ClubWatchlistVotePublic(ClubWatchlistVoteBase): + id: uuid.UUID + watchlist_entry_id: uuid.UUID + user_id: uuid.UUID + created_at: datetime + + # Generic message class Message(SQLModel): message: str diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000000..a70b3029a5 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +# Services package diff --git a/backend/app/services/omdb.py b/backend/app/services/omdb.py new file mode 100644 index 0000000000..285fdb5837 --- /dev/null +++ b/backend/app/services/omdb.py @@ -0,0 +1,175 @@ +"""OMDB API Integration Service""" + +from datetime import datetime, timedelta + +import httpx +from sqlmodel import Session, select + +from app.core.config import settings +from app.models import Movie, MovieSearchResult, get_datetime_utc + + +class OMDBError(Exception): + """Custom exception for OMDB API errors""" + + pass + + +class OMDBService: + """Service for interacting with the OMDB API with caching""" + + def __init__(self) -> None: + self.api_key = settings.OMDB_API_KEY + self.base_url = settings.OMDB_BASE_URL + self.cache_ttl_days = settings.OMDB_CACHE_TTL_DAYS + + if not self.api_key: + raise OMDBError("OMDB_API_KEY not configured") + + async def search_movies( + self, + *, + query: str, + year: str | None = None, + type: str | None = None, + page: int = 1, + ) -> tuple[list[MovieSearchResult], int]: + """ + Search OMDB for movies by title. + Returns tuple of (results, total_results) + """ + params: dict[str, str | int] = { + "apikey": self.api_key, # type: ignore + "s": query, + "page": page, + } + if year: + params["y"] = year + if type: + params["type"] = type + + async with httpx.AsyncClient() as client: + response = await client.get(self.base_url, params=params) + response.raise_for_status() + data = response.json() + + if data.get("Response") == "False": + error = data.get("Error", "Unknown error") + if error == "Movie not found!": + return [], 0 + raise OMDBError(error) + + results = [] + for item in data.get("Search", []): + poster = item.get("Poster") + results.append( + MovieSearchResult( + imdb_id=item.get("imdbID", ""), + title=item.get("Title", ""), + year=item.get("Year"), + poster_url=poster if poster and poster != "N/A" else None, + type=item.get("Type"), + ) + ) + + total_results = int(data.get("totalResults", 0)) + return results, total_results + + async def fetch_movie_details(self, *, imdb_id: str) -> dict: + """Fetch full movie details from OMDB by IMDB ID""" + params = { + "apikey": self.api_key, + "i": imdb_id, + "plot": "full", + } + + async with httpx.AsyncClient() as client: + response = await client.get(self.base_url, params=params) + response.raise_for_status() + data = response.json() + + if data.get("Response") == "False": + raise OMDBError(data.get("Error", "Movie not found")) + + return data + + def _clean_omdb_value(self, value: str | None) -> str | None: + """Return None for N/A values from OMDB""" + if value is None or value == "N/A": + return None + return value + + def _parse_omdb_response(self, data: dict) -> Movie: + """Parse OMDB response into Movie model""" + return Movie( + imdb_id=data.get("imdbID", ""), + title=data.get("Title", ""), + year=self._clean_omdb_value(data.get("Year")), + rated=self._clean_omdb_value(data.get("Rated")), + released=self._clean_omdb_value(data.get("Released")), + runtime=self._clean_omdb_value(data.get("Runtime")), + genre=self._clean_omdb_value(data.get("Genre")), + director=self._clean_omdb_value(data.get("Director")), + writer=self._clean_omdb_value(data.get("Writer")), + actors=self._clean_omdb_value(data.get("Actors")), + plot=self._clean_omdb_value(data.get("Plot")), + language=self._clean_omdb_value(data.get("Language")), + country=self._clean_omdb_value(data.get("Country")), + awards=self._clean_omdb_value(data.get("Awards")), + poster_url=self._clean_omdb_value(data.get("Poster")), + imdb_rating=self._clean_omdb_value(data.get("imdbRating")), + imdb_votes=self._clean_omdb_value(data.get("imdbVotes")), + box_office=self._clean_omdb_value(data.get("BoxOffice")), + fetched_at=get_datetime_utc(), + raw_data=data, + ) + + def _is_cache_stale(self, movie: Movie) -> bool: + """Check if cached movie data is stale""" + if movie.fetched_at is None: + return True + expiry = movie.fetched_at + timedelta(days=self.cache_ttl_days) + return datetime.now(movie.fetched_at.tzinfo) > expiry + + async def get_or_fetch_movie( + self, + *, + session: Session, + imdb_id: str, + force_refresh: bool = False, + ) -> Movie: + """ + Get movie from cache or fetch from OMDB. + Refreshes if cache is stale or force_refresh is True. + """ + # Check cache first + statement = select(Movie).where(Movie.imdb_id == imdb_id) + cached_movie = session.exec(statement).first() + + if cached_movie and not force_refresh and not self._is_cache_stale(cached_movie): + return cached_movie + + # Fetch from OMDB + data = await self.fetch_movie_details(imdb_id=imdb_id) + + if cached_movie: + # Update existing cache + movie_data = self._parse_omdb_response(data) + update_dict = movie_data.model_dump(exclude={"id"}) + cached_movie.sqlmodel_update(update_dict) + session.add(cached_movie) + session.commit() + session.refresh(cached_movie) + return cached_movie + else: + # Create new cache entry + new_movie = self._parse_omdb_response(data) + session.add(new_movie) + session.commit() + session.refresh(new_movie) + return new_movie + + +def get_omdb_service() -> OMDBService: + """Factory function to create OMDB service instance""" + return OMDBService() diff --git a/frontend/index.html b/frontend/index.html index 57621a268b..d1fdc93ace 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - Full Stack FastAPI Project + Vantage diff --git a/frontend/package.json b/frontend/package.json index 0040e7ff03..e4154999b5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-alert-dialog": "^1.1.12", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", @@ -46,6 +47,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.17", + "use-debounce": "^10.0.4", "zod": "^4.3.6" }, "devDependencies": { diff --git a/frontend/public/assets/images/vantage-icon-light.svg b/frontend/public/assets/images/vantage-icon-light.svg new file mode 100644 index 0000000000..5f50c5f5f2 --- /dev/null +++ b/frontend/public/assets/images/vantage-icon-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/assets/images/vantage-icon.svg b/frontend/public/assets/images/vantage-icon.svg new file mode 100644 index 0000000000..d3be7394a2 --- /dev/null +++ b/frontend/public/assets/images/vantage-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/assets/images/vantage-logo-light.svg b/frontend/public/assets/images/vantage-logo-light.svg new file mode 100644 index 0000000000..8510f96a53 --- /dev/null +++ b/frontend/public/assets/images/vantage-logo-light.svg @@ -0,0 +1,4 @@ + + + Vantage + diff --git a/frontend/public/assets/images/vantage-logo.svg b/frontend/public/assets/images/vantage-logo.svg new file mode 100644 index 0000000000..7d2fc78fde --- /dev/null +++ b/frontend/public/assets/images/vantage-logo.svg @@ -0,0 +1,4 @@ + + + Vantage + diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 5c0c9c4a4e..91473e4878 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -57,198 +57,1244 @@ export const Body_login_login_access_tokenSchema = { title: 'Body_login-login_access_token' } as const; +export const ClubCreateSchema = { + properties: { + name: { + type: 'string', + maxLength: 100, + minLength: 1, + title: 'Name' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + visibility: { + '$ref': '#/components/schemas/ClubVisibility', + default: 'public' + }, + rules: { + anyOf: [ + { + type: 'string', + maxLength: 2000 + }, + { + type: 'null' + } + ], + title: 'Rules' + }, + theme_color: { + anyOf: [ + { + type: 'string', + maxLength: 20 + }, + { + type: 'null' + } + ], + title: 'Theme Color' + } + }, + type: 'object', + required: ['name'], + title: 'ClubCreate' +} as const; + +export const ClubMemberPublicSchema = { + properties: { + role: { + '$ref': '#/components/schemas/MemberRole', + default: 'member' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + club_id: { + type: 'string', + format: 'uuid', + title: 'Club Id' + }, + user_id: { + type: 'string', + format: 'uuid', + title: 'User Id' + }, + joined_at: { + type: 'string', + format: 'date-time', + title: 'Joined At' + } + }, + type: 'object', + required: ['id', 'club_id', 'user_id', 'joined_at'], + title: 'ClubMemberPublic' +} as const; + +export const ClubMemberWithUserSchema = { + properties: { + role: { + '$ref': '#/components/schemas/MemberRole', + default: 'member' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + club_id: { + type: 'string', + format: 'uuid', + title: 'Club Id' + }, + user_id: { + type: 'string', + format: 'uuid', + title: 'User Id' + }, + joined_at: { + type: 'string', + format: 'date-time', + title: 'Joined At' + }, + user: { + '$ref': '#/components/schemas/UserPublic' + } + }, + type: 'object', + required: ['id', 'club_id', 'user_id', 'joined_at', 'user'], + title: 'ClubMemberWithUser', + description: 'Club member with user details' +} as const; + +export const ClubPublicSchema = { + properties: { + name: { + type: 'string', + maxLength: 100, + minLength: 1, + title: 'Name' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + cover_image_url: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Cover Image Url' + }, + visibility: { + '$ref': '#/components/schemas/ClubVisibility', + default: 'public' + }, + rules: { + anyOf: [ + { + type: 'string', + maxLength: 2000 + }, + { + type: 'null' + } + ], + title: 'Rules' + }, + theme_color: { + anyOf: [ + { + type: 'string', + maxLength: 20 + }, + { + type: 'null' + } + ], + title: 'Theme Color' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + } + }, + type: 'object', + required: ['name', 'id', 'created_at', 'updated_at'], + title: 'ClubPublic' +} as const; + +export const ClubUpdateSchema = { + properties: { + name: { + anyOf: [ + { + type: 'string', + maxLength: 100, + minLength: 1 + }, + { + type: 'null' + } + ], + title: 'Name' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + cover_image_url: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Cover Image Url' + }, + visibility: { + anyOf: [ + { + '$ref': '#/components/schemas/ClubVisibility' + }, + { + type: 'null' + } + ] + }, + rules: { + anyOf: [ + { + type: 'string', + maxLength: 2000 + }, + { + type: 'null' + } + ], + title: 'Rules' + }, + theme_color: { + anyOf: [ + { + type: 'string', + maxLength: 20 + }, + { + type: 'null' + } + ], + title: 'Theme Color' + } + }, + type: 'object', + title: 'ClubUpdate' +} as const; + +export const ClubVisibilitySchema = { + type: 'string', + enum: ['public', 'private', 'invite_only'], + title: 'ClubVisibility' +} as const; + +export const ClubWatchlistCreateSchema = { + properties: { + movie_imdb_id: { + type: 'string', + maxLength: 20, + title: 'Movie Imdb Id' + }, + notes: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Notes' + } + }, + type: 'object', + required: ['movie_imdb_id'], + title: 'ClubWatchlistCreate' +} as const; + +export const ClubWatchlistPublicSchema = { + properties: { + notes: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Notes' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + club_id: { + type: 'string', + format: 'uuid', + title: 'Club Id' + }, + movie_id: { + type: 'string', + format: 'uuid', + title: 'Movie Id' + }, + added_by_user_id: { + type: 'string', + format: 'uuid', + title: 'Added By User Id' + }, + added_at: { + type: 'string', + format: 'date-time', + title: 'Added At' + } + }, + type: 'object', + required: ['id', 'club_id', 'movie_id', 'added_by_user_id', 'added_at'], + title: 'ClubWatchlistPublic' +} as const; + +export const ClubWatchlistVotePublicSchema = { + properties: { + vote_type: { + '$ref': '#/components/schemas/VoteType' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + watchlist_entry_id: { + type: 'string', + format: 'uuid', + title: 'Watchlist Entry Id' + }, + user_id: { + type: 'string', + format: 'uuid', + title: 'User Id' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + } + }, + type: 'object', + required: ['vote_type', 'id', 'watchlist_entry_id', 'user_id', 'created_at'], + title: 'ClubWatchlistVotePublic' +} as const; + +export const ClubWatchlistWithMovieSchema = { + properties: { + notes: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Notes' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + club_id: { + type: 'string', + format: 'uuid', + title: 'Club Id' + }, + movie_id: { + type: 'string', + format: 'uuid', + title: 'Movie Id' + }, + added_by_user_id: { + type: 'string', + format: 'uuid', + title: 'Added By User Id' + }, + added_at: { + type: 'string', + format: 'date-time', + title: 'Added At' + }, + movie: { + '$ref': '#/components/schemas/MoviePublic' + }, + upvotes: { + type: 'integer', + title: 'Upvotes', + default: 0 + }, + downvotes: { + type: 'integer', + title: 'Downvotes', + default: 0 + }, + user_vote: { + anyOf: [ + { + '$ref': '#/components/schemas/VoteType' + }, + { + type: 'null' + } + ] + } + }, + type: 'object', + required: ['id', 'club_id', 'movie_id', 'added_by_user_id', 'added_at', 'movie'], + title: 'ClubWatchlistWithMovie', + description: 'Club watchlist entry with movie details and vote counts' +} as const; + +export const ClubWatchlistsPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/ClubWatchlistWithMovie' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'ClubWatchlistsPublic' +} as const; + +export const ClubWithMembersSchema = { + properties: { + name: { + type: 'string', + maxLength: 100, + minLength: 1, + title: 'Name' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + cover_image_url: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Cover Image Url' + }, + visibility: { + '$ref': '#/components/schemas/ClubVisibility', + default: 'public' + }, + rules: { + anyOf: [ + { + type: 'string', + maxLength: 2000 + }, + { + type: 'null' + } + ], + title: 'Rules' + }, + theme_color: { + anyOf: [ + { + type: 'string', + maxLength: 20 + }, + { + type: 'null' + } + ], + title: 'Theme Color' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + }, + members: { + items: { + '$ref': '#/components/schemas/ClubMemberWithUser' + }, + type: 'array', + title: 'Members' + }, + member_count: { + type: 'integer', + title: 'Member Count' + } + }, + type: 'object', + required: ['name', 'id', 'created_at', 'updated_at', 'members', 'member_count'], + title: 'ClubWithMembers', + description: 'Club with member list' +} as const; + +export const ClubsPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/ClubPublic' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'ClubsPublic' +} as const; + export const HTTPValidationErrorSchema = { properties: { - detail: { + detail: { + items: { + '$ref': '#/components/schemas/ValidationError' + }, + type: 'array', + title: 'Detail' + } + }, + type: 'object', + title: 'HTTPValidationError' +} as const; + +export const ItemCreateSchema = { + properties: { + title: { + type: 'string', + maxLength: 255, + minLength: 1, + title: 'Title' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Description' + } + }, + type: 'object', + required: ['title'], + title: 'ItemCreate' +} as const; + +export const ItemPublicSchema = { + properties: { + title: { + type: 'string', + maxLength: 255, + minLength: 1, + title: 'Title' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + owner_id: { + type: 'string', + format: 'uuid', + title: 'Owner Id' + }, + created_at: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Created At' + } + }, + type: 'object', + required: ['title', 'id', 'owner_id'], + title: 'ItemPublic' +} as const; + +export const ItemUpdateSchema = { + properties: { + title: { + anyOf: [ + { + type: 'string', + maxLength: 255, + minLength: 1 + }, + { + type: 'null' + } + ], + title: 'Title' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Description' + } + }, + type: 'object', + title: 'ItemUpdate' +} as const; + +export const ItemsPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/ItemPublic' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'ItemsPublic' +} as const; + +export const MemberRoleSchema = { + type: 'string', + enum: ['owner', 'admin', 'member', 'pending'], + title: 'MemberRole' +} as const; + +export const MessageSchema = { + properties: { + message: { + type: 'string', + title: 'Message' + } + }, + type: 'object', + required: ['message'], + title: 'Message' +} as const; + +export const MoviePublicSchema = { + properties: { + imdb_id: { + type: 'string', + maxLength: 20, + title: 'Imdb Id' + }, + title: { + type: 'string', + maxLength: 500, + title: 'Title' + }, + year: { + anyOf: [ + { + type: 'string', + maxLength: 10 + }, + { + type: 'null' + } + ], + title: 'Year' + }, + rated: { + anyOf: [ + { + type: 'string', + maxLength: 20 + }, + { + type: 'null' + } + ], + title: 'Rated' + }, + released: { + anyOf: [ + { + type: 'string', + maxLength: 50 + }, + { + type: 'null' + } + ], + title: 'Released' + }, + runtime: { + anyOf: [ + { + type: 'string', + maxLength: 20 + }, + { + type: 'null' + } + ], + title: 'Runtime' + }, + genre: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Genre' + }, + director: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Director' + }, + writer: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Writer' + }, + actors: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Actors' + }, + plot: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Plot' + }, + language: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Language' + }, + country: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Country' + }, + awards: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Awards' + }, + poster_url: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Poster Url' + }, + imdb_rating: { + anyOf: [ + { + type: 'string', + maxLength: 10 + }, + { + type: 'null' + } + ], + title: 'Imdb Rating' + }, + imdb_votes: { + anyOf: [ + { + type: 'string', + maxLength: 20 + }, + { + type: 'null' + } + ], + title: 'Imdb Votes' + }, + box_office: { + anyOf: [ + { + type: 'string', + maxLength: 50 + }, + { + type: 'null' + } + ], + title: 'Box Office' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + fetched_at: { + type: 'string', + format: 'date-time', + title: 'Fetched At' + } + }, + type: 'object', + required: ['imdb_id', 'title', 'id', 'fetched_at'], + title: 'MoviePublic' +} as const; + +export const MovieRatingStatsSchema = { + properties: { + movie_id: { + type: 'string', + format: 'uuid', + title: 'Movie Id' + }, + average_rating: { + type: 'number', + title: 'Average Rating' + }, + rating_count: { + type: 'integer', + title: 'Rating Count' + } + }, + type: 'object', + required: ['movie_id', 'average_rating', 'rating_count'], + title: 'MovieRatingStats', + description: 'Aggregated rating statistics for a movie' +} as const; + +export const MovieSearchPublicSchema = { + properties: { + data: { items: { - '$ref': '#/components/schemas/ValidationError' + '$ref': '#/components/schemas/MovieSearchResult' }, type: 'array', - title: 'Detail' + title: 'Data' + }, + total_results: { + type: 'integer', + title: 'Total Results' } }, type: 'object', - title: 'HTTPValidationError' + required: ['data', 'total_results'], + title: 'MovieSearchPublic' } as const; -export const ItemCreateSchema = { +export const MovieSearchResultSchema = { properties: { + imdb_id: { + type: 'string', + title: 'Imdb Id' + }, title: { type: 'string', - maxLength: 255, - minLength: 1, title: 'Title' }, - description: { + year: { anyOf: [ { - type: 'string', - maxLength: 255 + type: 'string' }, { type: 'null' } ], - title: 'Description' + title: 'Year' + }, + poster_url: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Poster Url' + }, + type: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Type' } }, type: 'object', - required: ['title'], - title: 'ItemCreate' + required: ['imdb_id', 'title'], + title: 'MovieSearchResult', + description: 'Lightweight movie result from OMDB search' } as const; -export const ItemPublicSchema = { +export const NewPasswordSchema = { properties: { - title: { + token: { type: 'string', - maxLength: 255, - minLength: 1, - title: 'Title' + title: 'Token' }, - description: { + new_password: { + type: 'string', + maxLength: 128, + minLength: 8, + title: 'New Password' + } + }, + type: 'object', + required: ['token', 'new_password'], + title: 'NewPassword' +} as const; + +export const PrivateUserCreateSchema = { + properties: { + email: { + type: 'string', + title: 'Email' + }, + password: { + type: 'string', + title: 'Password' + }, + full_name: { + type: 'string', + title: 'Full Name' + }, + is_verified: { + type: 'boolean', + title: 'Is Verified', + default: false + } + }, + type: 'object', + required: ['email', 'password', 'full_name'], + title: 'PrivateUserCreate' +} as const; + +export const RatingCreateSchema = { + properties: { + movie_imdb_id: { + type: 'string', + maxLength: 20, + title: 'Movie Imdb Id' + }, + score: { + type: 'number', + maximum: 5, + minimum: 1, + title: 'Score' + }, + club_id: { anyOf: [ { type: 'string', - maxLength: 255 + format: 'uuid' }, { type: 'null' } ], - title: 'Description' + title: 'Club Id' + } + }, + type: 'object', + required: ['movie_imdb_id', 'score'], + title: 'RatingCreate' +} as const; + +export const RatingPublicSchema = { + properties: { + score: { + type: 'number', + maximum: 5, + minimum: 1, + title: 'Score' }, id: { type: 'string', format: 'uuid', title: 'Id' }, - owner_id: { + movie_id: { type: 'string', format: 'uuid', - title: 'Owner Id' + title: 'Movie Id' }, created_at: { - anyOf: [ - { - type: 'string', - format: 'date-time' - }, - { - type: 'null' - } - ], + type: 'string', + format: 'date-time', title: 'Created At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' } }, type: 'object', - required: ['title', 'id', 'owner_id'], - title: 'ItemPublic' + required: ['score', 'id', 'movie_id', 'created_at', 'updated_at'], + title: 'RatingPublic' } as const; -export const ItemUpdateSchema = { +export const RatingUpdateSchema = { properties: { - title: { - anyOf: [ - { - type: 'string', - maxLength: 255, - minLength: 1 - }, - { - type: 'null' - } - ], - title: 'Title' - }, - description: { + score: { anyOf: [ { - type: 'string', - maxLength: 255 + type: 'number', + maximum: 5, + minimum: 1 }, { type: 'null' } ], - title: 'Description' + title: 'Score' } }, type: 'object', - title: 'ItemUpdate' + title: 'RatingUpdate' } as const; -export const ItemsPublicSchema = { +export const RatingWithMovieSchema = { properties: { - data: { - items: { - '$ref': '#/components/schemas/ItemPublic' - }, - type: 'array', - title: 'Data' + score: { + type: 'number', + maximum: 5, + minimum: 1, + title: 'Score' }, - count: { - type: 'integer', - title: 'Count' - } - }, - type: 'object', - required: ['data', 'count'], - title: 'ItemsPublic' -} as const; - -export const MessageSchema = { - properties: { - message: { + id: { type: 'string', - title: 'Message' - } - }, - type: 'object', - required: ['message'], - title: 'Message' -} as const; - -export const NewPasswordSchema = { - properties: { - token: { + format: 'uuid', + title: 'Id' + }, + movie_id: { type: 'string', - title: 'Token' + format: 'uuid', + title: 'Movie Id' }, - new_password: { + created_at: { type: 'string', - maxLength: 128, - minLength: 8, - title: 'New Password' + format: 'date-time', + title: 'Created At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + }, + movie: { + '$ref': '#/components/schemas/MoviePublic' } }, type: 'object', - required: ['token', 'new_password'], - title: 'NewPassword' + required: ['score', 'id', 'movie_id', 'created_at', 'updated_at', 'movie'], + title: 'RatingWithMovie', + description: 'Rating with full movie details' } as const; -export const PrivateUserCreateSchema = { +export const RatingsPublicSchema = { properties: { - email: { - type: 'string', - title: 'Email' - }, - password: { - type: 'string', - title: 'Password' - }, - full_name: { - type: 'string', - title: 'Full Name' + data: { + items: { + '$ref': '#/components/schemas/RatingWithMovie' + }, + type: 'array', + title: 'Data' }, - is_verified: { - type: 'boolean', - title: 'Is Verified', - default: false + count: { + type: 'integer', + title: 'Count' } }, type: 'object', - required: ['email', 'password', 'full_name'], - title: 'PrivateUserCreate' + required: ['data', 'count'], + title: 'RatingsPublic' } as const; export const TokenSchema = { @@ -502,6 +1548,201 @@ export const UserUpdateMeSchema = { title: 'UserUpdateMe' } as const; +export const UserWatchlistCreateSchema = { + properties: { + movie_imdb_id: { + type: 'string', + maxLength: 20, + title: 'Movie Imdb Id' + }, + status: { + '$ref': '#/components/schemas/WatchlistStatus', + default: 'want_to_watch' + }, + notes: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Notes' + } + }, + type: 'object', + required: ['movie_imdb_id'], + title: 'UserWatchlistCreate' +} as const; + +export const UserWatchlistPublicSchema = { + properties: { + status: { + '$ref': '#/components/schemas/WatchlistStatus', + default: 'want_to_watch' + }, + notes: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Notes' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + movie_id: { + type: 'string', + format: 'uuid', + title: 'Movie Id' + }, + watched_at: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Watched At' + }, + added_at: { + type: 'string', + format: 'date-time', + title: 'Added At' + } + }, + type: 'object', + required: ['id', 'movie_id', 'added_at'], + title: 'UserWatchlistPublic' +} as const; + +export const UserWatchlistUpdateSchema = { + properties: { + status: { + anyOf: [ + { + '$ref': '#/components/schemas/WatchlistStatus' + }, + { + type: 'null' + } + ] + }, + notes: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Notes' + }, + watched_at: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Watched At' + } + }, + type: 'object', + title: 'UserWatchlistUpdate' +} as const; + +export const UserWatchlistWithMovieSchema = { + properties: { + status: { + '$ref': '#/components/schemas/WatchlistStatus', + default: 'want_to_watch' + }, + notes: { + anyOf: [ + { + type: 'string', + maxLength: 1000 + }, + { + type: 'null' + } + ], + title: 'Notes' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + movie_id: { + type: 'string', + format: 'uuid', + title: 'Movie Id' + }, + watched_at: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Watched At' + }, + added_at: { + type: 'string', + format: 'date-time', + title: 'Added At' + }, + movie: { + '$ref': '#/components/schemas/MoviePublic' + } + }, + type: 'object', + required: ['id', 'movie_id', 'added_at', 'movie'], + title: 'UserWatchlistWithMovie', + description: 'Watchlist entry with full movie details' +} as const; + +export const UserWatchlistsPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/UserWatchlistWithMovie' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'UserWatchlistsPublic' +} as const; + export const UsersPublicSchema = { properties: { data: { @@ -549,4 +1790,16 @@ export const ValidationErrorSchema = { type: 'object', required: ['loc', 'msg', 'type'], title: 'ValidationError' +} as const; + +export const VoteTypeSchema = { + type: 'string', + enum: ['upvote', 'downvote'], + title: 'VoteType' +} as const; + +export const WatchlistStatusSchema = { + type: 'string', + enum: ['want_to_watch', 'watched'], + title: 'WatchlistStatus' } as const; \ No newline at end of file diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index ba79e3f726..38a61c2c68 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,312 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen'; +import type { ClubsListClubsData, ClubsListClubsResponse, ClubsCreateClubData, ClubsCreateClubResponse, ClubsGetClubData, ClubsGetClubResponse, ClubsUpdateClubData, ClubsUpdateClubResponse, ClubsDeleteClubData, ClubsDeleteClubResponse, ClubsJoinClubData, ClubsJoinClubResponse, ClubsLeaveClubData, ClubsLeaveClubResponse, ClubsUpdateMemberRoleData, ClubsUpdateMemberRoleResponse, ClubsRemoveMemberData, ClubsRemoveMemberResponse, ClubsGetClubWatchlistData, ClubsGetClubWatchlistResponse, ClubsAddToClubWatchlistData, ClubsAddToClubWatchlistResponse, ClubsRemoveFromClubWatchlistData, ClubsRemoveFromClubWatchlistResponse, ClubsVoteOnWatchlistEntryData, ClubsVoteOnWatchlistEntryResponse, ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MoviesSearchMoviesData, MoviesSearchMoviesResponse, MoviesGetMovieData, MoviesGetMovieResponse, MoviesGetMovieRatingsData, MoviesGetMovieRatingsResponse, PrivateCreateUserData, PrivateCreateUserResponse, RatingsCreateRatingData, RatingsCreateRatingResponse, RatingsGetMyRatingsData, RatingsGetMyRatingsResponse, RatingsUpdateRatingData, RatingsUpdateRatingResponse, RatingsDeleteRatingData, RatingsDeleteRatingResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, WatchlistGetMyWatchlistData, WatchlistGetMyWatchlistResponse, WatchlistAddToWatchlistData, WatchlistAddToWatchlistResponse, WatchlistUpdateWatchlistEntryData, WatchlistUpdateWatchlistEntryResponse, WatchlistRemoveFromWatchlistData, WatchlistRemoveFromWatchlistResponse } from './types.gen'; + +export class ClubsService { + /** + * List Clubs + * List all public clubs and clubs the user is a member of. + * @param data The data for the request. + * @param data.skip + * @param data.limit + * @returns ClubsPublic Successful Response + * @throws ApiError + */ + public static listClubs(data: ClubsListClubsData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/clubs/', + query: { + skip: data.skip, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Create Club + * Create a new club. The creator becomes the owner. + * @param data The data for the request. + * @param data.requestBody + * @returns ClubPublic Successful Response + * @throws ApiError + */ + public static createClub(data: ClubsCreateClubData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/clubs/', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Get Club + * Get club details with member list. + * @param data The data for the request. + * @param data.clubId + * @returns ClubWithMembers Successful Response + * @throws ApiError + */ + public static getClub(data: ClubsGetClubData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/clubs/{club_id}', + path: { + club_id: data.clubId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update Club + * Update club details. Requires admin or owner. + * @param data The data for the request. + * @param data.clubId + * @param data.requestBody + * @returns ClubPublic Successful Response + * @throws ApiError + */ + public static updateClub(data: ClubsUpdateClubData): CancelablePromise { + return __request(OpenAPI, { + method: 'PATCH', + url: '/api/v1/clubs/{club_id}', + path: { + club_id: data.clubId + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Delete Club + * Delete a club. Requires owner. + * @param data The data for the request. + * @param data.clubId + * @returns Message Successful Response + * @throws ApiError + */ + public static deleteClub(data: ClubsDeleteClubData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/clubs/{club_id}', + path: { + club_id: data.clubId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Join Club + * Join a club. For invite-only clubs, creates pending membership. + * @param data The data for the request. + * @param data.clubId + * @returns ClubMemberPublic Successful Response + * @throws ApiError + */ + public static joinClub(data: ClubsJoinClubData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/clubs/{club_id}/join', + path: { + club_id: data.clubId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Leave Club + * Leave a club. + * @param data The data for the request. + * @param data.clubId + * @returns Message Successful Response + * @throws ApiError + */ + public static leaveClub(data: ClubsLeaveClubData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/clubs/{club_id}/leave', + path: { + club_id: data.clubId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update Member Role + * Update a member's role. Requires admin (for member changes) or owner (for admin changes). + * @param data The data for the request. + * @param data.clubId + * @param data.userId + * @param data.role + * @returns ClubMemberPublic Successful Response + * @throws ApiError + */ + public static updateMemberRole(data: ClubsUpdateMemberRoleData): CancelablePromise { + return __request(OpenAPI, { + method: 'PATCH', + url: '/api/v1/clubs/{club_id}/members/{user_id}', + path: { + club_id: data.clubId, + user_id: data.userId + }, + query: { + role: data.role + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Remove Member + * Remove a member from the club. Requires admin or owner. + * @param data The data for the request. + * @param data.clubId + * @param data.userId + * @returns Message Successful Response + * @throws ApiError + */ + public static removeMember(data: ClubsRemoveMemberData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/clubs/{club_id}/members/{user_id}', + path: { + club_id: data.clubId, + user_id: data.userId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Get Club Watchlist + * Get club's watchlist with movies and vote counts. + * @param data The data for the request. + * @param data.clubId + * @param data.skip + * @param data.limit + * @returns ClubWatchlistsPublic Successful Response + * @throws ApiError + */ + public static getClubWatchlist(data: ClubsGetClubWatchlistData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/clubs/{club_id}/watchlist', + path: { + club_id: data.clubId + }, + query: { + skip: data.skip, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Add To Club Watchlist + * Add a movie to the club's watchlist. + * @param data The data for the request. + * @param data.clubId + * @param data.requestBody + * @returns ClubWatchlistPublic Successful Response + * @throws ApiError + */ + public static addToClubWatchlist(data: ClubsAddToClubWatchlistData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/clubs/{club_id}/watchlist', + path: { + club_id: data.clubId + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Remove From Club Watchlist + * Remove a movie from the club's watchlist. + * Requires admin/owner or being the user who added it. + * @param data The data for the request. + * @param data.clubId + * @param data.entryId + * @returns Message Successful Response + * @throws ApiError + */ + public static removeFromClubWatchlist(data: ClubsRemoveFromClubWatchlistData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/clubs/{club_id}/watchlist/{entry_id}', + path: { + club_id: data.clubId, + entry_id: data.entryId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Vote On Watchlist Entry + * Vote on a watchlist entry. Toggle if voting same type again. + * @param data The data for the request. + * @param data.clubId + * @param data.entryId + * @param data.voteType + * @returns ClubWatchlistVotePublic Successful Response + * @throws ApiError + */ + public static voteOnWatchlistEntry(data: ClubsVoteOnWatchlistEntryData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/clubs/{club_id}/watchlist/{entry_id}/vote', + path: { + club_id: data.clubId, + entry_id: data.entryId + }, + query: { + vote_type: data.voteType + }, + errors: { + 422: 'Validation Error' + } + }); + } +} export class ItemsService { /** @@ -213,6 +518,83 @@ export class LoginService { } } +export class MoviesService { + /** + * Search Movies + * Search movies via OMDB API. + * Results are not cached (search results change frequently). + * @param data The data for the request. + * @param data.q Search query + * @param data.year Filter by year + * @param data.type Filter by type: movie, series, episode + * @param data.page Page number + * @returns MovieSearchPublic Successful Response + * @throws ApiError + */ + public static searchMovies(data: MoviesSearchMoviesData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/movies/search', + query: { + q: data.q, + year: data.year, + type: data.type, + page: data.page + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Get Movie + * Get movie details by IMDB ID. + * Fetches from OMDB and caches if not already cached or stale. + * @param data The data for the request. + * @param data.imdbId + * @param data.refresh Force refresh from OMDB + * @returns MoviePublic Successful Response + * @throws ApiError + */ + public static getMovie(data: MoviesGetMovieData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/movies/{imdb_id}', + path: { + imdb_id: data.imdbId + }, + query: { + refresh: data.refresh + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Get Movie Ratings + * Get aggregated rating stats for a movie. + * @param data The data for the request. + * @param data.imdbId + * @returns MovieRatingStats Successful Response + * @throws ApiError + */ + public static getMovieRatings(data: MoviesGetMovieRatingsData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/movies/{imdb_id}/ratings', + path: { + imdb_id: data.imdbId + }, + errors: { + 422: 'Validation Error' + } + }); + } +} + export class PrivateService { /** * Create User @@ -235,6 +617,97 @@ export class PrivateService { } } +export class RatingsService { + /** + * Create Rating + * Create or update a rating for a movie. + * Only one rating per user per movie is allowed (upsert behavior). + * @param data The data for the request. + * @param data.requestBody + * @returns RatingPublic Successful Response + * @throws ApiError + */ + public static createRating(data: RatingsCreateRatingData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/ratings/', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Get My Ratings + * Get current user's ratings with movie details. + * @param data The data for the request. + * @param data.skip + * @param data.limit + * @returns RatingsPublic Successful Response + * @throws ApiError + */ + public static getMyRatings(data: RatingsGetMyRatingsData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/ratings/me', + query: { + skip: data.skip, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update Rating + * Update a rating score. + * @param data The data for the request. + * @param data.ratingId + * @param data.requestBody + * @returns RatingPublic Successful Response + * @throws ApiError + */ + public static updateRating(data: RatingsUpdateRatingData): CancelablePromise { + return __request(OpenAPI, { + method: 'PATCH', + url: '/api/v1/ratings/{rating_id}', + path: { + rating_id: data.ratingId + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Delete Rating + * Delete a rating. + * @param data The data for the request. + * @param data.ratingId + * @returns Message Successful Response + * @throws ApiError + */ + public static deleteRating(data: RatingsDeleteRatingData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/ratings/{rating_id}', + path: { + rating_id: data.ratingId + }, + errors: { + 422: 'Validation Error' + } + }); + } +} + export class UsersService { /** * Read Users @@ -465,4 +938,97 @@ export class UtilsService { url: '/api/v1/utils/health-check/' }); } +} + +export class WatchlistService { + /** + * Get My Watchlist + * Get current user's watchlist with movie details. + * @param data The data for the request. + * @param data.status Filter by status + * @param data.skip + * @param data.limit + * @returns UserWatchlistsPublic Successful Response + * @throws ApiError + */ + public static getMyWatchlist(data: WatchlistGetMyWatchlistData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/users/me/watchlist/', + query: { + status: data.status, + skip: data.skip, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Add To Watchlist + * Add a movie to personal watchlist. + * Movie will be fetched and cached from OMDB if not already cached. + * @param data The data for the request. + * @param data.requestBody + * @returns UserWatchlistPublic Successful Response + * @throws ApiError + */ + public static addToWatchlist(data: WatchlistAddToWatchlistData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/users/me/watchlist/', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update Watchlist Entry + * Update a watchlist entry (status, notes, watched_at). + * @param data The data for the request. + * @param data.watchlistId + * @param data.requestBody + * @returns UserWatchlistPublic Successful Response + * @throws ApiError + */ + public static updateWatchlistEntry(data: WatchlistUpdateWatchlistEntryData): CancelablePromise { + return __request(OpenAPI, { + method: 'PATCH', + url: '/api/v1/users/me/watchlist/{watchlist_id}', + path: { + watchlist_id: data.watchlistId + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Remove From Watchlist + * Remove a movie from personal watchlist. + * @param data The data for the request. + * @param data.watchlistId + * @returns Message Successful Response + * @throws ApiError + */ + public static removeFromWatchlist(data: WatchlistRemoveFromWatchlistData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/users/me/watchlist/{watchlist_id}', + path: { + watchlist_id: data.watchlistId + }, + errors: { + 422: 'Validation Error' + } + }); + } } \ No newline at end of file diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index e62b56cad3..8bbedae1df 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -9,6 +9,122 @@ export type Body_login_login_access_token = { client_secret?: (string | null); }; +export type ClubCreate = { + name: string; + description?: (string | null); + visibility?: ClubVisibility; + rules?: (string | null); + theme_color?: (string | null); +}; + +export type ClubMemberPublic = { + role?: MemberRole; + id: string; + club_id: string; + user_id: string; + joined_at: string; +}; + +/** + * Club member with user details + */ +export type ClubMemberWithUser = { + role?: MemberRole; + id: string; + club_id: string; + user_id: string; + joined_at: string; + user: UserPublic; +}; + +export type ClubPublic = { + name: string; + description?: (string | null); + cover_image_url?: (string | null); + visibility?: ClubVisibility; + rules?: (string | null); + theme_color?: (string | null); + id: string; + created_at: string; + updated_at: string; +}; + +export type ClubsPublic = { + data: Array; + count: number; +}; + +export type ClubUpdate = { + name?: (string | null); + description?: (string | null); + cover_image_url?: (string | null); + visibility?: (ClubVisibility | null); + rules?: (string | null); + theme_color?: (string | null); +}; + +export type ClubVisibility = 'public' | 'private' | 'invite_only'; + +export type ClubWatchlistCreate = { + movie_imdb_id: string; + notes?: (string | null); +}; + +export type ClubWatchlistPublic = { + notes?: (string | null); + id: string; + club_id: string; + movie_id: string; + added_by_user_id: string; + added_at: string; +}; + +export type ClubWatchlistsPublic = { + data: Array; + count: number; +}; + +export type ClubWatchlistVotePublic = { + vote_type: VoteType; + id: string; + watchlist_entry_id: string; + user_id: string; + created_at: string; +}; + +/** + * Club watchlist entry with movie details and vote counts + */ +export type ClubWatchlistWithMovie = { + notes?: (string | null); + id: string; + club_id: string; + movie_id: string; + added_by_user_id: string; + added_at: string; + movie: MoviePublic; + upvotes?: number; + downvotes?: number; + user_vote?: (VoteType | null); +}; + +/** + * Club with member list + */ +export type ClubWithMembers = { + name: string; + description?: (string | null); + cover_image_url?: (string | null); + visibility?: ClubVisibility; + rules?: (string | null); + theme_color?: (string | null); + id: string; + created_at: string; + updated_at: string; + members: Array; + member_count: number; +}; + export type HTTPValidationError = { detail?: Array; }; @@ -36,10 +152,60 @@ export type ItemUpdate = { description?: (string | null); }; +export type MemberRole = 'owner' | 'admin' | 'member' | 'pending'; + export type Message = { message: string; }; +export type MoviePublic = { + imdb_id: string; + title: string; + year?: (string | null); + rated?: (string | null); + released?: (string | null); + runtime?: (string | null); + genre?: (string | null); + director?: (string | null); + writer?: (string | null); + actors?: (string | null); + plot?: (string | null); + language?: (string | null); + country?: (string | null); + awards?: (string | null); + poster_url?: (string | null); + imdb_rating?: (string | null); + imdb_votes?: (string | null); + box_office?: (string | null); + id: string; + fetched_at: string; +}; + +/** + * Aggregated rating statistics for a movie + */ +export type MovieRatingStats = { + movie_id: string; + average_rating: number; + rating_count: number; +}; + +export type MovieSearchPublic = { + data: Array; + total_results: number; +}; + +/** + * Lightweight movie result from OMDB search + */ +export type MovieSearchResult = { + imdb_id: string; + title: string; + year?: (string | null); + poster_url?: (string | null); + type?: (string | null); +}; + export type NewPassword = { token: string; new_password: string; @@ -52,6 +218,41 @@ export type PrivateUserCreate = { is_verified?: boolean; }; +export type RatingCreate = { + movie_imdb_id: string; + score: number; + club_id?: (string | null); +}; + +export type RatingPublic = { + score: number; + id: string; + movie_id: string; + created_at: string; + updated_at: string; +}; + +export type RatingsPublic = { + data: Array; + count: number; +}; + +export type RatingUpdate = { + score?: (number | null); +}; + +/** + * Rating with full movie details + */ +export type RatingWithMovie = { + score: number; + id: string; + movie_id: string; + created_at: string; + updated_at: string; + movie: MoviePublic; +}; + export type Token = { access_token: string; token_type?: string; @@ -103,12 +304,144 @@ export type UserUpdateMe = { email?: (string | null); }; +export type UserWatchlistCreate = { + movie_imdb_id: string; + status?: WatchlistStatus; + notes?: (string | null); +}; + +export type UserWatchlistPublic = { + status?: WatchlistStatus; + notes?: (string | null); + id: string; + movie_id: string; + watched_at?: (string | null); + added_at: string; +}; + +export type UserWatchlistsPublic = { + data: Array; + count: number; +}; + +export type UserWatchlistUpdate = { + status?: (WatchlistStatus | null); + notes?: (string | null); + watched_at?: (string | null); +}; + +/** + * Watchlist entry with full movie details + */ +export type UserWatchlistWithMovie = { + status?: WatchlistStatus; + notes?: (string | null); + id: string; + movie_id: string; + watched_at?: (string | null); + added_at: string; + movie: MoviePublic; +}; + export type ValidationError = { loc: Array<(string | number)>; msg: string; type: string; }; +export type VoteType = 'upvote' | 'downvote'; + +export type WatchlistStatus = 'want_to_watch' | 'watched'; + +export type ClubsListClubsData = { + limit?: number; + skip?: number; +}; + +export type ClubsListClubsResponse = (ClubsPublic); + +export type ClubsCreateClubData = { + requestBody: ClubCreate; +}; + +export type ClubsCreateClubResponse = (ClubPublic); + +export type ClubsGetClubData = { + clubId: string; +}; + +export type ClubsGetClubResponse = (ClubWithMembers); + +export type ClubsUpdateClubData = { + clubId: string; + requestBody: ClubUpdate; +}; + +export type ClubsUpdateClubResponse = (ClubPublic); + +export type ClubsDeleteClubData = { + clubId: string; +}; + +export type ClubsDeleteClubResponse = (Message); + +export type ClubsJoinClubData = { + clubId: string; +}; + +export type ClubsJoinClubResponse = (ClubMemberPublic); + +export type ClubsLeaveClubData = { + clubId: string; +}; + +export type ClubsLeaveClubResponse = (Message); + +export type ClubsUpdateMemberRoleData = { + clubId: string; + role: MemberRole; + userId: string; +}; + +export type ClubsUpdateMemberRoleResponse = (ClubMemberPublic); + +export type ClubsRemoveMemberData = { + clubId: string; + userId: string; +}; + +export type ClubsRemoveMemberResponse = (Message); + +export type ClubsGetClubWatchlistData = { + clubId: string; + limit?: number; + skip?: number; +}; + +export type ClubsGetClubWatchlistResponse = (ClubWatchlistsPublic); + +export type ClubsAddToClubWatchlistData = { + clubId: string; + requestBody: ClubWatchlistCreate; +}; + +export type ClubsAddToClubWatchlistResponse = (ClubWatchlistPublic); + +export type ClubsRemoveFromClubWatchlistData = { + clubId: string; + entryId: string; +}; + +export type ClubsRemoveFromClubWatchlistResponse = (Message); + +export type ClubsVoteOnWatchlistEntryData = { + clubId: string; + entryId: string; + voteType: VoteType; +}; + +export type ClubsVoteOnWatchlistEntryResponse = (ClubWatchlistVotePublic); + export type ItemsReadItemsData = { limit?: number; skip?: number; @@ -167,12 +500,75 @@ export type LoginRecoverPasswordHtmlContentData = { export type LoginRecoverPasswordHtmlContentResponse = (string); +export type MoviesSearchMoviesData = { + /** + * Page number + */ + page?: number; + /** + * Search query + */ + q: string; + /** + * Filter by type: movie, series, episode + */ + type?: (string | null); + /** + * Filter by year + */ + year?: (string | null); +}; + +export type MoviesSearchMoviesResponse = (MovieSearchPublic); + +export type MoviesGetMovieData = { + imdbId: string; + /** + * Force refresh from OMDB + */ + refresh?: boolean; +}; + +export type MoviesGetMovieResponse = (MoviePublic); + +export type MoviesGetMovieRatingsData = { + imdbId: string; +}; + +export type MoviesGetMovieRatingsResponse = (MovieRatingStats); + export type PrivateCreateUserData = { requestBody: PrivateUserCreate; }; export type PrivateCreateUserResponse = (UserPublic); +export type RatingsCreateRatingData = { + requestBody: RatingCreate; +}; + +export type RatingsCreateRatingResponse = (RatingPublic); + +export type RatingsGetMyRatingsData = { + limit?: number; + skip?: number; +}; + +export type RatingsGetMyRatingsResponse = (RatingsPublic); + +export type RatingsUpdateRatingData = { + ratingId: string; + requestBody: RatingUpdate; +}; + +export type RatingsUpdateRatingResponse = (RatingPublic); + +export type RatingsDeleteRatingData = { + ratingId: string; +}; + +export type RatingsDeleteRatingResponse = (Message); + export type UsersReadUsersData = { limit?: number; skip?: number; @@ -233,4 +629,34 @@ export type UtilsTestEmailData = { export type UtilsTestEmailResponse = (Message); -export type UtilsHealthCheckResponse = (boolean); \ No newline at end of file +export type UtilsHealthCheckResponse = (boolean); + +export type WatchlistGetMyWatchlistData = { + limit?: number; + skip?: number; + /** + * Filter by status + */ + status?: (WatchlistStatus | null); +}; + +export type WatchlistGetMyWatchlistResponse = (UserWatchlistsPublic); + +export type WatchlistAddToWatchlistData = { + requestBody: UserWatchlistCreate; +}; + +export type WatchlistAddToWatchlistResponse = (UserWatchlistPublic); + +export type WatchlistUpdateWatchlistEntryData = { + requestBody: UserWatchlistUpdate; + watchlistId: string; +}; + +export type WatchlistUpdateWatchlistEntryResponse = (UserWatchlistPublic); + +export type WatchlistRemoveFromWatchlistData = { + watchlistId: string; +}; + +export type WatchlistRemoveFromWatchlistResponse = (Message); \ No newline at end of file diff --git a/frontend/src/components/Clubs/AddMovieToClubDialog.tsx b/frontend/src/components/Clubs/AddMovieToClubDialog.tsx new file mode 100644 index 0000000000..933e6c04c1 --- /dev/null +++ b/frontend/src/components/Clubs/AddMovieToClubDialog.tsx @@ -0,0 +1,212 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { Plus, Search } from "lucide-react" +import { useState } from "react" +import { useDebouncedCallback } from "use-debounce" + +import type { MovieSearchResult } from "@/client" +import { ClubsService, MoviesService } from "@/client" +import { MoviePoster } from "@/components/Movies/MoviePoster" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Skeleton } from "@/components/ui/skeleton" +import { Textarea } from "@/components/ui/textarea" +import useCustomToast from "@/hooks/useCustomToast" +import { handleError } from "@/utils" + +interface AddMovieToClubDialogProps { + clubId: string +} + +export function AddMovieToClubDialog({ clubId }: AddMovieToClubDialogProps) { + const [open, setOpen] = useState(false) + const [searchQuery, setSearchQuery] = useState("") + const [debouncedQuery, setDebouncedQuery] = useState("") + const [selectedMovie, setSelectedMovie] = useState( + null + ) + const [notes, setNotes] = useState("") + const queryClient = useQueryClient() + const { showSuccessToast, showErrorToast } = useCustomToast() + + const handleSearch = useDebouncedCallback((value: string) => { + setDebouncedQuery(value) + }, 300) + + const { data: searchResults, isLoading } = useQuery({ + queryKey: ["movies", "search", debouncedQuery], + queryFn: () => MoviesService.searchMovies({ q: debouncedQuery }), + enabled: debouncedQuery.length >= 2, + }) + + const addMovieMutation = useMutation({ + mutationFn: () => + ClubsService.addToClubWatchlist({ + clubId, + requestBody: { + movie_imdb_id: selectedMovie!.imdb_id, + notes: notes || undefined, + }, + }), + onSuccess: () => { + showSuccessToast("Movie added to watchlist!") + queryClient.invalidateQueries({ queryKey: ["clubs", clubId, "watchlist"] }) + setOpen(false) + setSelectedMovie(null) + setNotes("") + setSearchQuery("") + setDebouncedQuery("") + }, + onError: handleError.bind(showErrorToast), + }) + + const handleSelectMovie = (movie: MovieSearchResult) => { + setSelectedMovie(movie) + } + + const handleBack = () => { + setSelectedMovie(null) + setNotes("") + } + + return ( + + + + + + + + {selectedMovie ? "Add to Watchlist" : "Search Movies"} + + + {selectedMovie + ? "Add a note for the club (optional)" + : "Search for a movie to add to the club's watchlist"} + + + + {selectedMovie ? ( +
+
+
+ +
+
+

{selectedMovie.title}

+

+ {selectedMovie.year} +

+
+
+ +
+ +