diff --git a/.ai/ARCHITECTURE.md b/.ai/ARCHITECTURE.md index 871050d..f7161e3 100644 --- a/.ai/ARCHITECTURE.md +++ b/.ai/ARCHITECTURE.md @@ -61,7 +61,7 @@ wishpicks/ | Migrations | Alembic | Version-controlled schema changes | | Validation | Pydantic v2 | Request/response schemas, settings management | | Auth | Custom JWT (`python-jose`) | Zero cost, full control, no vendor lock-in | -| Password hashing | bcrypt via `passlib` | Industry standard, cost factor 12 | +| Password hashing | `bcrypt` (direct) | passlib incompatible with bcrypt >= 4.0; use bcrypt library directly | | URL scraping | `httpx` + `BeautifulSoup4` | Async HTTP + HTML parsing for product import | | Rate limiting | slowapi + Redis (Upstash) | Persistent rate limit counters across restarts and multiple workers | | Cache | Redis (Upstash) | URL parser cache, session blocklist; free serverless tier via HTTP | diff --git a/.ai/DESIGN.md b/.ai/DESIGN.md new file mode 100644 index 0000000..2c0302d --- /dev/null +++ b/.ai/DESIGN.md @@ -0,0 +1,209 @@ +# Design System (Minimalist Black & White) + +## 1. Visual Theme & Atmosphere + +This design system is built on confident minimalism — a black-and-white interface where every element has a clear purpose and nothing exists purely for decoration. + +The entire experience is based on a strict duality: +- deep black (`#121212`) +- pure white (`#ffffff`) + +There are no unnecessary mid-tones or visual noise. + +Typography relies on geometric sans-serif fonts with a clean and engineered feel: +- headings are bold and authoritative +- body text is highly readable and functional + +The interface makes heavy use of: +- **pill-shaped elements (999px border-radius)** +- **card-based layouts** +- **subtle shadows** +- **compact, information-dense spacing** + +--- + +## 2. Color Palette & Roles + +See `apps\web\assets\css\main.css` for color palette of the project. Use this for building interfaces, don't make up the new colors +without any need. + +### Principle +- No gradients +- Only solid colors + +--- + +## 3. Typography Rules + +### Font Family +- Primary: `system-ui`, `Inter`, `DM Sans` + +### Hierarchy + +| Role | Size | Weight | Line Height | +|------|------|--------|------------| +| Hero | 52px | 700 | 1.23 | +| Section Heading | 36px | 700 | 1.22 | +| Card Title | 32px | 700 | 1.25 | +| Subheading | 24px | 700 | 1.33 | +| Small Heading | 20px | 700 | 1.40 | +| UI Text | 18px | 500 | 1.33 | +| Body | 16px | 400-500 | 1.25-1.50 | +| Caption | 14px | 400 | 1.4 | +| Micro | 12px | 400 | 1.6 | + +### Principles +- Headings are always bold +- Body text is medium or regular weight +- No decorative typography + +--- + +## 4. Component Stylings + +### Buttons + +**Primary** +- Background: Black +- Text: White +- Padding: 10px 12px +- Radius: 999px + +**Secondary** +- Background: White +- Text: Black +- Hover: `#e2e2e2` +- Radius: 999px + +**Chip** +- Background: `#efefef` +- Radius: 999px + +**Floating Action** +- Background: White +- Shadow: `rgba(0,0,0,0.16)` +- Radius: 999px + +--- + +### Cards +- Radius: 8px (standard), 12px (featured) +- Shadow: `rgba(0,0,0,0.12)` +- No borders + +--- + +### Inputs +- Border: 1px solid black +- Radius: 8px +- Background: white + +--- + +### Navigation +- Sticky top bar +- Minimal design +- Pill-style navigation elements + +--- + +## 5. Layout Principles + +### Spacing +- Base unit: 8px +- Scale: 4px → 32px + +### Container +- Max width: ~1136px + +### Philosophy +- Efficiency over airiness +- High information density + +--- + +## 6. Depth & Elevation + +| Level | Treatment | +|------|----------| +| 0 | No shadow | +| 1 | `rgba(0,0,0,0.12)` | +| 2 | `rgba(0,0,0,0.16)` | +| 3 | Floating elements | +| 4 | Inset (pressed state) | + +--- + +## 7. Nuxt UI Usage Guidelines + +### Core Principle + +When building the interface, **prefer using Nuxt UI components whenever possible**. + +### Rules + +- Do NOT create custom components if: + - an equivalent exists in Nuxt UI + - it can be adapted via props, slots, or styling + +### When Custom Markup is Allowed + +Use plain HTML + CSS (or Tailwind) only if: +- the required component does not exist in Nuxt UI +- the behavior is too specific or complex +- a unique layout cannot be achieved with existing components + +### Anti-patterns + +- ❌ Rebuilding buttons from scratch +- ❌ Duplicating Nuxt UI logic +- ❌ Mixing too many custom and library components inconsistently + +### Goal + +- UI consistency +- faster development +- easier maintenance + +--- + +## 8. Do's and Don'ts + +### Do +- Use black and white as the primary palette +- Use pill-shaped buttons and controls +- Keep layouts compact +- Use subtle shadows + +### Don't +- Do not use gradients +- Do not introduce unnecessary colors +- Do not create overly spacious layouts +- Do not use heavy shadows + +--- + +## 9. Responsive Behavior + +### Breakpoints + +| Name | Width | +|------|------| +| Mobile | 320–600px | +| Tablet | 768–1119px | +| Desktop | 1120px+ | + +### Rules +- Layouts stack vertically on smaller screens +- Buttons must be at least 44px height +- Grids collapse into single column + +--- + +## 10. Agent Prompt Guide + +### Principles +- Be explicit about colors (`#121212`, `#ffffff`) +- Always specify border-radius for buttons (999px) +- Keep layouts compact and structured +- Prefer consistency over creativity \ No newline at end of file diff --git a/.ai/sessions/2026-04-30-phase1-foundation.md b/.ai/sessions/2026-04-30-phase1-foundation.md new file mode 100644 index 0000000..e36de33 --- /dev/null +++ b/.ai/sessions/2026-04-30-phase1-foundation.md @@ -0,0 +1,45 @@ +# Phase 1 Foundation + +## Description + +Implemented the full Phase 1 foundation: initial database schema migration (all 7 tables) and complete auth system with JWT HTTP-only cookies, refresh token rotation, and theft detection. + +## Session Log + +1. Created `migrations/versions/6fc915cac3e1_initial_schema.py` via `alembic revision --autogenerate`, then manually patched `ix_users_google_id` to be a partial index (`WHERE google_id IS NOT NULL`) — autogenerate cannot detect partial index conditions. + +2. Built `app/core/security.py` — JWT create/decode utilities using python-jose. + +3. Extracted `app/core/limiter.py` — slowapi Limiter instance lives here (not in main.py) to avoid circular imports, since routers import limiter and main.py imports routers. + +4. Built `app/schemas/auth.py` — RegisterRequest, LoginRequest, UserResponse, AuthResponse. Required switching `pydantic` to `pydantic[email]` for EmailStr. + +5. Replaced `passlib` with direct `bcrypt` usage in `app/services/auth.py`. passlib raises `ValueError: password cannot be longer than 72 bytes` during `CryptContext` initialization (`detect_wrap_bug()`) when bcrypt >= 4.0 is installed. Removed passlib from `pyproject.toml`. + +6. Built full `app/services/auth.py`: register, login, logout, refresh_tokens (rotation + theft detection), `_revoke_all_user_tokens`, `_issue_tokens` helper. + +7. Built `app/dependencies/get_current_user.py` — reads access_token from Cookie, validates JWT type=="access", loads User from DB. + +8. Built `app/routers/auth.py` — 5 endpoints: POST /register (201, 3/min), POST /login (200, 5/min), POST /logout (204, 10/min), POST /refresh (200, 10/min), GET /me (200, 10/min). Each needs `Request` as first param for slowapi. + +9. Updated `app/main.py` — wired slowapi, disabled OpenAPI in production, added custom HTTPException handler to enforce `{"data":{}}` / `{"error":{}}` envelope (FastAPI's default wraps detail in `{"detail": ...}`). + +10. Removed all tests from the project. `tests/` directory deleted, pytest/pytest-asyncio removed from `pyproject.toml`. + +11. Created `apps/api/README.md` and `apps/web/README.md`. + +## Session Outcomes + +- All 7 tables created and migrated: users, wishlists, wish_items, reservations, refresh_tokens, saved_wishlists, saved_items +- Auth endpoints fully functional and verified via curl +- `ruff check app/` passes cleanly +- Both READMEs written + +## Lessons Learned + +- **passlib + bcrypt >= 4.0 incompatible**: use `bcrypt` directly — `bcrypt.hashpw()` / `bcrypt.checkpw()` +- **Alembic partial indexes**: autogenerate misses `WHERE` clause — always review and patch manually after generation +- **slowapi circular import**: keep Limiter in its own `core/limiter.py`, not in `main.py` +- **FastAPI HTTPException envelope**: default behavior wraps `detail` in `{"detail": ...}` — need a custom exception handler to enforce project envelope format +- **Swagger Set-Cookie**: browsers block reading Set-Cookie as a forbidden header — this is expected behavior, not a bug; use curl `-v` or Network tab to verify cookies are set +- **asyncpg + Windows**: incompatible with ProactorEventLoop — all backend CLI commands must run inside Docker (`docker exec wishpicks-api-1 ...`) diff --git a/.ai/sessions/2026-05-05-google-oauth-api.md b/.ai/sessions/2026-05-05-google-oauth-api.md new file mode 100644 index 0000000..5fb16e6 --- /dev/null +++ b/.ai/sessions/2026-05-05-google-oauth-api.md @@ -0,0 +1,38 @@ +# Google OAuth — Full Implementation (API + Web) + +## Description + +Implemented Google OAuth login flow end-to-end: FastAPI backend handles the full code exchange and cookie issuance; the Nuxt frontend had most of the UI pre-built, the only missing piece was surfacing the `?error=oauth_failed` redirect on the login page. + +## Session Log + +### API + +1. Created `app/services/google_oauth.py` — `get_google_auth_url()` builds the consent screen URL; `exchange_code_for_profile(code)` exchanges the authorization code for a Google userinfo profile via httpx; `upsert_google_user(db, profile)` applies 3-step upsert: match by `google_id` → match by email (links `google_id` to existing account) → create new Google-only account (no `password_hash`). + +2. Added `google_login(db, code)` to `app/services/auth.py` — orchestrates the google_oauth service + existing `_issue_tokens`, follows the same return shape `(user, access_token, refresh_token)` as `register` and `login`. + +3. Added `GET /api/auth/google` and `GET /api/auth/google/callback` to `app/routers/auth.py`. The callback redirects to `{FRONTEND_URL}/dashboard` on success with JWT HTTP-only cookies set, or to `{FRONTEND_URL}/login?error=oauth_failed` on any failure (missing code, Google error param, exchange failure). Rate limit: 10/minute/IP. + +4. Discovered that `docker restart` does not re-read `env_file` — must use `docker compose up -d --force-recreate api` to pick up `.env` changes. + +### Web + +5. The frontend was already ~95% built: Google buttons on login/register pages, `loginWithGoogle()` composable, `googleAuthUrl()` API method, `/auth/google/callback` loading page, and all translations existed. `GOOGLE_REDIRECT_URI` points to the backend (`localhost:8000/api/auth/google/callback`), so the backend owns the full OAuth exchange and redirects the browser to `/dashboard` directly — the frontend callback page is not in the active redirect path. + +6. The only missing piece: `pages/login.vue` did not read the `?error` query param, so users who denied access on Google's consent screen were silently redirected to `/login` with no feedback. Fixed by initialising `errorCode` from `route.query.error` via `useRoute()`. + +7. Added `auth.errors.oauth_failed` to `locales/en.json` ("Google sign-in failed. Please try again.") and `locales/uk.json` ("Вхід через Google не вдався. Спробуй ще раз."). + +## Session Outcomes + +- `GET /api/auth/google` and `GET /api/auth/google/callback` fully functional +- Smoke tested with real Google credentials — redirect URL contains correct `client_id` +- Error fallbacks verified: missing code and `error=access_denied` both redirect to `/login?error=oauth_failed` +- `ruff check` passes on all modified API files +- Login page now surfaces `oauth_failed` error message in both locales when Google auth is denied or fails + +## Lessons Learned + +- **`docker restart` vs `--force-recreate`**: `restart` keeps the existing environment snapshot; `env_file` changes only take effect after `docker compose up --force-recreate`. +- **Frontend OAuth callback page vs direct dashboard redirect**: `GOOGLE_REDIRECT_URI` points to the backend, so the backend sets cookies and redirects the browser to `/dashboard` directly. The `/auth/google/callback` Nuxt page exists but is not in the active flow — `auth.client.ts` plugin calls `initialize()` on every page load, so landing on `/dashboard` with cookies already set works correctly. diff --git a/.github/cliff.toml b/.github/cliff.toml new file mode 100644 index 0000000..70a696b --- /dev/null +++ b/.github/cliff.toml @@ -0,0 +1,29 @@ +[changelog] +header = "" +body = """ +{% for group, commits in commits | group_by(attribute="group") %}\ +### {{ group | upper_first }} + +{% for commit in commits %}\ +- {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message }} ([`{{ commit.id | truncate(length=7, end="") }}`](https://github.com/denvudd/wishpicks/commit/{{ commit.id }})) +{% endfor %} +{% endfor %} +""" +trim = true + +[git] +conventional_commits = true +filter_unconventional = true +commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactor" }, + { message = "^docs", group = "Documentation" }, + { message = "^chore|^ci|^build|^style|^test", group = "Miscellaneous" }, + { message = "^revert", group = "Reverted" }, +] +filter_commits = false +tag_pattern = "v[0-9]*" +topo_order = false +sort_commits = "oldest" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fb6f9a2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +on: + pull_request: + branches: [main] + +jobs: + lint-api: + name: Lint API + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/api + + steps: + - uses: actions/checkout@v4 + + - name: Setup uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --dev + + - name: Ruff lint + run: uv run ruff check . + + - name: Ruff format check + run: uv run ruff format --check . + + lint-web: + name: Lint Web + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + env: + HUSKY: 0 + + - name: ESLint + working-directory: apps/web + run: npm run lint + + - name: Typecheck + working-directory: apps/web + run: npx nuxt typecheck diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml new file mode 100644 index 0000000..25d3d20 --- /dev/null +++ b/.github/workflows/commitlint.yml @@ -0,0 +1,30 @@ +name: Commitlint + +on: + pull_request: + branches: [main] + +jobs: + commitlint: + name: Check commit messages + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + env: + HUSKY: 0 + + - name: Validate commit messages + run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..764a72c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,34 @@ +name: Release + +on: + push: + tags: + - "v[0-9]*.[0-9]*.[0-9]*" + +permissions: + contents: write + +jobs: + release: + name: Create GitHub Release + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate changelog + id: cliff + uses: orhun/git-cliff-action@v4 + with: + config: .github/cliff.toml + args: --latest --strip header + + - name: Create GitHub Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "${{ github.ref_name }}" \ + --title "Release ${{ github.ref_name }}" \ + --notes-file "${{ steps.cliff.outputs.changelog }}" diff --git a/.gitignore b/.gitignore index 9afb7a1..b0162d9 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ Thumbs.db # Claude /.ai/superpowers/ .claude/ +docs/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9edbc0a --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# Wishpicks + +A wishlist platform. Users create gift wishlists and share links with friends. Friends can reserve items to avoid duplicate gifts — the wishlist owner never sees who reserved what. + +## Stack + +| Layer | Technology | +|-------|-----------| +| Frontend | Nuxt 3, Pinia, Tailwind CSS v4, Nuxt UI | +| Backend | FastAPI, SQLAlchemy 2 (async), Alembic, Pydantic v2 | +| Database | PostgreSQL (Neon in production) | +| Cache / Rate limiting | Redis (Upstash in production) | +| Media | Cloudinary | +| Frontend hosting | Vercel | +| Backend hosting | Railway / Render | + +## Project structure + +``` +wishpicks/ +├── apps/ +│ ├── web/ # Nuxt 3 frontend +│ └── api/ # FastAPI backend +├── .github/ +│ ├── workflows/ # CI/CD (see below) +│ └── cliff.toml # Changelog format config +├── .ai/ # Architecture docs, conventions +├── docker-compose.yml +└── .env.example +``` + +## Getting started + +**Prerequisites:** Docker, Docker Compose + +```bash +# 1. Copy env file and fill in the required values +cp .env.example .env + +# 2. Start all services (Postgres, Redis, API, Web) +docker-compose up +``` + +| Service | URL | +|---------|-----| +| Frontend | http://localhost:3000 | +| Backend | http://localhost:8000 | +| API docs | http://localhost:8000/docs | + +## Environment variables + +All variables are documented in `.env.example`. Required before first run: + +Variables without values in `.env.example` are optional for local development (the features that depend on them won't work, but the app will start). + +## Commit convention + +This project uses [Conventional Commits](https://www.conventionalcommits.org). The format is enforced locally via Husky and in CI via the commitlint workflow. + +``` +(): + +feat(api): add reservation endpoint +fix(web): correct auth redirect on 401 +chore: update dependencies +feat!: rename endpoint ← breaking change (! suffix) +``` + +Allowed types: `feat`, `fix`, `chore`, `docs`, `refactor`, `test`, `style`, `perf`, `ci`, `build`, `revert` + +Scope is optional and must be kebab-case when provided. + +## CI/CD + +### Workflows + +**`ci.yml`** — runs on every PR to `main` + +Runs two parallel jobs: `Lint API` (ruff check + format check) and `Lint Web` (eslint + nuxt typecheck). Both jobs always run regardless of which files changed. + +**`commitlint.yml`** — runs on every PR to `main` + +Validates that every commit title in the PR follows the Conventional Commits format. Fails the PR if any commit message is invalid. + +**`release.yml`** — runs when a `v*.*.*` tag is pushed + +Generates a changelog from commits since the previous tag using [git-cliff](https://github.com/orhun/git-cliff), then creates a GitHub Release with that changelog as the release notes. + +### Creating a release + +Decide the version bump based on what changed: + +| Change type | Bump | Example | +|-------------|------|---------| +| Bug fixes only | patch | `v1.0.0` → `v1.0.1` | +| New features, backward compatible | minor | `v1.0.0` → `v1.1.0` | +| Breaking changes | major | `v1.0.0` → `v2.0.0` | + +Then push a tag: + +```bash +git tag v1.1.0 -m "Release v1.1.0" +git push origin v1.1.0 +``` + +GitHub Actions picks up the tag, generates the changelog automatically, and publishes the release. No manual steps needed beyond pushing the tag. diff --git a/apps/api/README.md b/apps/api/README.md new file mode 100644 index 0000000..38262fb --- /dev/null +++ b/apps/api/README.md @@ -0,0 +1,162 @@ +# Wishpicks API + +FastAPI backend for the Wishpicks wishlist platform. + +## Stack + +| Concern | Technology | +|---|---| +| Framework | FastAPI | +| ORM | SQLAlchemy 2.x (async) | +| DB driver | asyncpg | +| Migrations | Alembic | +| Validation | Pydantic v2 | +| Auth | JWT via python-jose, bcrypt | +| Rate limiting | slowapi + Redis | +| Cache | Redis (Upstash in prod) | +| Package manager | uv | + +## Local development + +All services run through Docker Compose from the repo root: + +```bash +# Start everything (Postgres + Redis + API + Web) +docker-compose up + +# API is available at http://localhost:8000 +# Swagger UI at http://localhost:8000/docs (development only) +``` + +The API container mounts `./apps/api` as a volume and runs uvicorn with `--reload`, so file changes are picked up immediately without rebuilding. + +## Environment variables + +Copy `.env.example` (repo root) and fill in the values. The API container reads from that file via `env_file` in docker-compose. + +| Variable | Description | +|---|---| +| `DATABASE_URL` | asyncpg connection string — use `postgres` as host inside Docker | +| `SECRET_KEY` | JWT signing secret, min 32 chars. Generate: `python -c "import secrets; print(secrets.token_hex(32))"` | +| `ACCESS_TOKEN_EXPIRE_MINUTES` | Default: `15` | +| `REFRESH_TOKEN_EXPIRE_DAYS` | Default: `30` | +| `GOOGLE_CLIENT_ID` | Google OAuth app | +| `GOOGLE_CLIENT_SECRET` | Google OAuth app | +| `GOOGLE_REDIRECT_URI` | Must match exactly what's registered in Google Console | +| `CLOUDINARY_CLOUD_NAME` | Cloudinary account | +| `CLOUDINARY_API_KEY` | Cloudinary account | +| `CLOUDINARY_API_SECRET` | Cloudinary account | +| `FRONTEND_URL` | Used for CORS and CSRF validation | +| `ENVIRONMENT` | `development` or `production` | +| `REDIS_URL` | Use `redis://redis:6379` inside Docker | + +## Project structure + +``` +app/ +├── core/ +│ ├── settings.py # Typed config via pydantic-settings +│ ├── db.py # Async SQLAlchemy engine + session factory +│ ├── redis.py # Redis client with graceful degradation +│ ├── security.py # JWT create/decode utilities +│ └── limiter.py # slowapi limiter instance +├── models/ +│ ├── base.py # DeclarativeBase, UUIDMixin, TimestampMixin +│ └── enums.py # EventType, ItemPriority +├── schemas/ # Pydantic request/response schemas (one file per domain) +├── services/ # Business logic +├── routers/ # HTTP layer — one file per domain +├── dependencies/ +│ ├── get_db.py # Yields AsyncSession +│ ├── get_redis.py # Yields Redis | None +│ └── get_current_user.py # get_current_user / optional_current_user +├── middleware/ +│ ├── cors.py +│ ├── csrf.py # Origin/Referer validation (production only) +│ └── security_headers.py +└── main.py # App factory, middleware, routers +migrations/ +└── versions/ # Alembic migration files +``` + +## Alembic migrations + +```bash +# Run inside the api container +docker exec wishpicks-api-1 uv run alembic upgrade head + +# Generate a new migration after model changes +docker exec wishpicks-api-1 uv run alembic revision --autogenerate -m "describe_the_change" +# Always review the generated file before applying — autogenerate misses partial indexes, +# check constraints, and changes inside Postgres enum types. + +# Check current migration state +docker exec wishpicks-api-1 uv run alembic current +``` + +## Linting and formatting + +```bash +docker exec wishpicks-api-1 uv run ruff check app/ +docker exec wishpicks-api-1 uv run ruff format app/ +``` + +Or locally if you have uv installed: + +```bash +cd apps/api +uv run ruff check app/ +uv run ruff format app/ +``` + +## Authentication + +Tokens live **only in HTTP-only cookies** — never in the response body or localStorage. + +| Cookie | Lifetime | Path | +|---|---|---| +| `access_token` | 15 min | `/` | +| `refresh_token` | 30 days | `/api/auth/refresh` | + +**Refresh token rotation:** every `/api/auth/refresh` call issues a new pair and revokes the old JTI in the DB. If a revoked JTI is replayed (theft detection), all sessions for that user are immediately revoked. + +## Key architecture rules + +- **All business logic in `services/`** — routers only handle HTTP concerns (cookies, status codes, response models). +- **Never expose auto-increment IDs** — all public-facing IDs are UUID v4. +- **All DB changes via Alembic** — never use `Base.metadata.create_all()` in application code. +- **Owner never sees reserver identity** — enforced at the serialization layer in response schemas, not in the frontend. +- **Redis failures are non-fatal** — all Redis operations fall through gracefully; Postgres is the source of truth. +- **Swagger/OpenAPI disabled in production** — set `ENVIRONMENT=production` to disable `/docs`, `/redoc`, `/openapi.json`. + +## API response format + +All responses follow the same envelope: + +```json +// Success +{ "data": { ... } } + +// Error +{ "error": { "code": "SCREAMING_SNAKE_CASE", "message": "Human-readable description." } } + +// Validation error +{ "error": { "code": "VALIDATION_ERROR", "message": "...", "details": [...] } } +``` + +## Rate limits + +| Endpoint | Limit | +|---|---| +| `POST /api/auth/register` | 3 / minute / IP | +| `POST /api/auth/login` | 5 / minute / IP | +| All other auth endpoints | 10 / minute / IP | +| `POST /api/items/parse-url` | 10 / minute / authenticated user | + +## Rebuilding the Docker image + +Required when `pyproject.toml` dependencies change: + +```bash +docker-compose up -d --build api +``` diff --git a/apps/api/app/core/limiter.py b/apps/api/app/core/limiter.py new file mode 100644 index 0000000..38404a8 --- /dev/null +++ b/apps/api/app/core/limiter.py @@ -0,0 +1,4 @@ +from slowapi import Limiter +from slowapi.util import get_remote_address + +limiter = Limiter(key_func=get_remote_address) diff --git a/apps/api/app/core/security.py b/apps/api/app/core/security.py new file mode 100644 index 0000000..84789b5 --- /dev/null +++ b/apps/api/app/core/security.py @@ -0,0 +1,31 @@ +import uuid +from datetime import UTC, datetime, timedelta + +from jose import jwt + +from app.core.settings import settings + +_ALGORITHM = "HS256" + + +def create_access_token(user_id: uuid.UUID) -> str: + expire = datetime.now(UTC) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + return jwt.encode( + {"sub": str(user_id), "exp": expire, "type": "access"}, + settings.SECRET_KEY, + algorithm=_ALGORITHM, + ) + + +def create_refresh_token(user_id: uuid.UUID, jti: uuid.UUID) -> str: + expire = datetime.now(UTC) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + return jwt.encode( + {"sub": str(user_id), "jti": str(jti), "exp": expire, "type": "refresh"}, + settings.SECRET_KEY, + algorithm=_ALGORITHM, + ) + + +def decode_token(token: str) -> dict: + """Decodes and validates token signature + expiry. Raises JWTError on failure.""" + return jwt.decode(token, settings.SECRET_KEY, algorithms=[_ALGORITHM]) diff --git a/apps/api/app/dependencies/get_current_user.py b/apps/api/app/dependencies/get_current_user.py index 11caaa7..6f6faa7 100644 --- a/apps/api/app/dependencies/get_current_user.py +++ b/apps/api/app/dependencies/get_current_user.py @@ -1,14 +1,50 @@ -from fastapi import HTTPException, status +import uuid -# TODO: replace with real JWT cookie validation in Phase 1 +from fastapi import Cookie, Depends, HTTPException, status +from jose import JWTError +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from app.core.security import decode_token +from app.dependencies.get_db import get_db +from app.models.user import User -async def get_current_user(): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail={"error": {"code": "NOT_AUTHENTICATED", "message": "Authentication required."}}, - ) +_NOT_AUTHENTICATED = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "error": {"code": "NOT_AUTHENTICATED", "message": "Authentication required."} + }, +) -async def optional_current_user(): - return None +async def get_current_user( + access_token: str | None = Cookie(default=None), + db: AsyncSession = Depends(get_db), +) -> User: + if not access_token: + raise _NOT_AUTHENTICATED + try: + payload = decode_token(access_token) + except JWTError: + raise _NOT_AUTHENTICATED + if payload.get("type") != "access": + raise _NOT_AUTHENTICATED + user_id = payload.get("sub") + if not user_id: + raise _NOT_AUTHENTICATED + user = await db.scalar(select(User).where(User.id == uuid.UUID(user_id))) + if not user or not user.is_active: + raise _NOT_AUTHENTICATED + return user + + +async def optional_current_user( + access_token: str | None = Cookie(default=None), + db: AsyncSession = Depends(get_db), +) -> User | None: + if not access_token: + return None + try: + return await get_current_user(access_token=access_token, db=db) + except HTTPException: + return None diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 8e365d9..8f03539 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -1,12 +1,36 @@ -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse +from slowapi import _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded +from app.core.limiter import limiter from app.core.settings import settings from app.middleware.cors import add_cors_middleware from app.middleware.csrf import add_csrf_middleware from app.middleware.security_headers import add_security_headers_middleware from app.routers import auth, items, media, reservations, saved, wishlists -app = FastAPI(title="Wishpicks API", version="0.1.0") +app = FastAPI( + title="Wishpicks API", + version="0.1.0", + docs_url="/docs" if settings.ENVIRONMENT != "production" else None, + redoc_url="/redoc" if settings.ENVIRONMENT != "production" else None, + openapi_url="/openapi.json" if settings.ENVIRONMENT != "production" else None, +) + +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + + +@app.exception_handler(HTTPException) +async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse: + detail = exc.detail + if isinstance(detail, dict) and "error" in detail: + body = detail + else: + body = {"error": {"code": "HTTP_ERROR", "message": str(detail)}} + return JSONResponse(status_code=exc.status_code, content=body) + add_cors_middleware(app) add_security_headers_middleware(app) diff --git a/apps/api/app/middleware/csrf.py b/apps/api/app/middleware/csrf.py index 624c5f1..23e6ad0 100644 --- a/apps/api/app/middleware/csrf.py +++ b/apps/api/app/middleware/csrf.py @@ -14,7 +14,9 @@ async def dispatch(self, request: Request, call_next): if not origin.startswith(settings.FRONTEND_URL): return JSONResponse( status_code=403, - content={"error": {"code": "FORBIDDEN", "message": "Invalid origin."}}, + content={ + "error": {"code": "FORBIDDEN", "message": "Invalid origin."} + }, ) return await call_next(request) diff --git a/apps/api/app/models/user.py b/apps/api/app/models/user.py index a12163b..d72f34c 100644 --- a/apps/api/app/models/user.py +++ b/apps/api/app/models/user.py @@ -12,7 +12,9 @@ class User(Base, UUIDMixin, TimestampMixin): display_name: Mapped[str | None] = mapped_column(String, nullable=True) avatar_url: Mapped[str | None] = mapped_column(String, nullable=True) password_hash: Mapped[str | None] = mapped_column(String, nullable=True) - google_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True, index=True) + google_id: Mapped[str | None] = mapped_column( + String, unique=True, nullable=True, index=True + ) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) wishlists: Mapped[list["Wishlist"]] = relationship( diff --git a/apps/api/app/models/wish_item.py b/apps/api/app/models/wish_item.py index 2ba988f..3a06be0 100644 --- a/apps/api/app/models/wish_item.py +++ b/apps/api/app/models/wish_item.py @@ -1,7 +1,16 @@ import uuid from decimal import Decimal -from sqlalchemy import UUID, Boolean, ForeignKey, Integer, Numeric, SmallInteger, String, Text +from sqlalchemy import ( + UUID, + Boolean, + ForeignKey, + Integer, + Numeric, + SmallInteger, + String, + Text, +) from sqlalchemy.orm import Mapped, mapped_column, relationship from app.models.base import Base, TimestampMixin, UUIDMixin diff --git a/apps/api/app/routers/auth.py b/apps/api/app/routers/auth.py index db9a3a7..f3d9aa4 100644 --- a/apps/api/app/routers/auth.py +++ b/apps/api/app/routers/auth.py @@ -1,4 +1,204 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response, status +from fastapi.responses import RedirectResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.limiter import limiter +from app.dependencies.get_current_user import get_current_user +from app.dependencies.get_db import get_db +from app.dependencies.get_redis import get_redis +from app.models.user import User +from app.schemas.auth import AuthResponse, LoginRequest, RegisterRequest, UserResponse +from app.services import auth as auth_service router = APIRouter() -# TODO: implement auth endpoints in Phase 1 + + +def _set_auth_cookies( + response: Response, access_token: str, refresh_token: str +) -> None: + from app.core.settings import settings + + secure = settings.ENVIRONMENT == "production" + response.set_cookie( + key="access_token", + value=access_token, + httponly=True, + secure=secure, + samesite="lax", + max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + ) + response.set_cookie( + key="refresh_token", + value=refresh_token, + httponly=True, + secure=secure, + samesite="lax", + max_age=settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60, + path="/api/auth/refresh", + ) + + +def _clear_auth_cookies(response: Response) -> None: + response.delete_cookie("access_token") + response.delete_cookie("refresh_token", path="/api/auth/refresh") + + +@router.post( + "/register", + response_model=AuthResponse, + status_code=201, + summary="Register a new user account", + responses={ + 409: {"description": "Email already registered"}, + 422: {"description": "Validation error"}, + }, +) +@limiter.limit("3/minute") +async def register( + request: Request, + data: RegisterRequest, + response: Response, + db: AsyncSession = Depends(get_db), +): + user, access_token, refresh_token = await auth_service.register(db, data) + _set_auth_cookies(response, access_token, refresh_token) + return AuthResponse(data=UserResponse.model_validate(user)) + + +@router.post( + "/login", + response_model=AuthResponse, + summary="Authenticate with email and password", + responses={401: {"description": "Invalid credentials"}}, +) +@limiter.limit("5/minute") +async def login( + request: Request, + data: LoginRequest, + response: Response, + db: AsyncSession = Depends(get_db), +): + user, access_token, refresh_token = await auth_service.login(db, data) + _set_auth_cookies(response, access_token, refresh_token) + return AuthResponse(data=UserResponse.model_validate(user)) + + +@router.post( + "/logout", + status_code=204, + summary="Invalidate the current session and clear auth cookies", +) +@limiter.limit("10/minute") +async def logout( + request: Request, + response: Response, + refresh_token: str | None = Cookie(default=None), + db: AsyncSession = Depends(get_db), +): + from jose import JWTError + + from app.core.security import decode_token + + jti_str = None + if refresh_token: + try: + payload = decode_token(refresh_token) + jti_str = payload.get("jti") + except JWTError: + pass + await auth_service.logout(db, jti_str) + _clear_auth_cookies(response) + + +@router.post( + "/refresh", + response_model=AuthResponse, + summary="Rotate access and refresh tokens", + responses={401: {"description": "Missing, invalid, or stolen refresh token"}}, +) +@limiter.limit("10/minute") +async def refresh( + request: Request, + response: Response, + refresh_token: str | None = Cookie(default=None), + db: AsyncSession = Depends(get_db), + redis=Depends(get_redis), +): + if not refresh_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "error": { + "code": "NOT_AUTHENTICATED", + "message": "Authentication required.", + } + }, + ) + user, access_token, new_refresh_token = await auth_service.refresh_tokens( + db, redis, refresh_token + ) + _set_auth_cookies(response, access_token, new_refresh_token) + return AuthResponse(data=UserResponse.model_validate(user)) + + +@router.get( + "/me", + response_model=AuthResponse, + summary="Return the currently authenticated user", + responses={401: {"description": "Not authenticated"}}, +) +@limiter.limit("10/minute") +async def me( + request: Request, + current_user: User = Depends(get_current_user), +): + return AuthResponse(data=UserResponse.model_validate(current_user)) + + +@router.get( + "/google", + summary="Redirect to Google OAuth consent screen", + status_code=302, + responses={302: {"description": "Redirect to Google consent screen"}}, +) +@limiter.limit("10/minute") +async def google_login(request: Request): + from app.services.google_oauth import get_google_auth_url + + return RedirectResponse(url=get_google_auth_url()) + + +@router.get( + "/google/callback", + summary="Handle Google OAuth callback, issue session cookies, redirect to frontend", + status_code=302, + responses={302: {"description": "Redirect to dashboard or login on failure"}}, +) +@limiter.limit("10/minute") +async def google_callback( + request: Request, + code: str | None = None, + error: str | None = None, + db: AsyncSession = Depends(get_db), +): + from app.core.settings import settings + + error_redirect = RedirectResponse( + url=f"{settings.FRONTEND_URL}/login?error=oauth_failed", + status_code=302, + ) + + if error or not code: + return error_redirect + + try: + user, access_token, refresh_token = await auth_service.google_login(db, code) + except Exception: + return error_redirect + + success_redirect = RedirectResponse( + url=f"{settings.FRONTEND_URL}/dashboard", + status_code=302, + ) + _set_auth_cookies(success_redirect, access_token, refresh_token) + return success_redirect diff --git a/apps/api/app/schemas/auth.py b/apps/api/app/schemas/auth.py index fe9159d..b5a5998 100644 --- a/apps/api/app/schemas/auth.py +++ b/apps/api/app/schemas/auth.py @@ -1 +1,55 @@ -# TODO: implement auth schemas in Phase 1 +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, EmailStr, field_validator + + +class RegisterRequest(BaseModel): + model_config = ConfigDict( + json_schema_extra={ + "example": { + "email": "user@example.com", + "password": "securepassword", + "display_name": "Jane Doe", + } + } + ) + + email: EmailStr + password: str + display_name: str | None = None + + @field_validator("password") + @classmethod + def password_min_length(cls, v: str) -> str: + if len(v) < 8: + raise ValueError("Must be at least 8 characters.") + return v + + +class LoginRequest(BaseModel): + model_config = ConfigDict( + json_schema_extra={ + "example": {"email": "user@example.com", "password": "securepassword"} + } + ) + + email: EmailStr + password: str + + +class UserResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + email: str + username: str | None + display_name: str | None + avatar_url: str | None + is_active: bool + created_at: datetime + updated_at: datetime + + +class AuthResponse(BaseModel): + data: UserResponse diff --git a/apps/api/app/services/auth.py b/apps/api/app/services/auth.py index 583fb45..a952df4 100644 --- a/apps/api/app/services/auth.py +++ b/apps/api/app/services/auth.py @@ -1 +1,215 @@ -# TODO: implement auth service in Phase 1 +import uuid +from datetime import UTC, datetime, timedelta + +import bcrypt +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import create_access_token, create_refresh_token +from app.core.settings import settings +from app.models.refresh_token import RefreshToken +from app.models.user import User +from app.schemas.auth import LoginRequest, RegisterRequest + + +def _hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)).decode() + + +def _verify_password(plain: str, hashed: str) -> bool: + return bcrypt.checkpw(plain.encode(), hashed.encode()) + + +async def _issue_tokens(db: AsyncSession, user_id: uuid.UUID) -> tuple[str, str]: + jti = uuid.uuid4() + expires_at = datetime.now(UTC) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + db.add(RefreshToken(jti=jti, user_id=user_id, expires_at=expires_at)) + await db.flush() + return create_access_token(user_id), create_refresh_token(user_id, jti) + + +async def register(db: AsyncSession, data: RegisterRequest) -> tuple[User, str, str]: + existing = await db.scalar(select(User).where(User.email == data.email)) + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "error": { + "code": "EMAIL_ALREADY_EXISTS", + "message": "Email is already registered.", + } + }, + ) + user = User( + email=data.email, + password_hash=_hash_password(data.password), + display_name=data.display_name, + ) + db.add(user) + await db.flush() + access_token, refresh_token = await _issue_tokens(db, user.id) + await db.commit() + await db.refresh(user) + return user, access_token, refresh_token + + +async def login(db: AsyncSession, data: LoginRequest) -> tuple[User, str, str]: + user = await db.scalar(select(User).where(User.email == data.email)) + _invalid = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "error": { + "code": "INVALID_CREDENTIALS", + "message": "Invalid email or password.", + } + }, + ) + if not user or not user.password_hash: + raise _invalid + if not _verify_password(data.password, user.password_hash): + raise _invalid + access_token, refresh_token = await _issue_tokens(db, user.id) + await db.commit() + await db.refresh(user) + return user, access_token, refresh_token + + +async def logout(db: AsyncSession, jti_str: str | None) -> None: + if not jti_str: + return + try: + jti = uuid.UUID(jti_str) + except ValueError: + return + token_record = await db.get(RefreshToken, jti) + if token_record: + token_record.revoked = True + await db.commit() + + +async def get_me(db: AsyncSession, user_id: uuid.UUID) -> User: + user = await db.get(User, user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "error": { + "code": "NOT_AUTHENTICATED", + "message": "Authentication required.", + } + }, + ) + return user + + +async def refresh_tokens( + db: AsyncSession, + redis, + refresh_token_str: str, +) -> tuple[User, str, str]: + from jose import JWTError + + from app.core.security import decode_token + + _invalid = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "error": { + "code": "NOT_AUTHENTICATED", + "message": "Authentication required.", + } + }, + ) + _stolen = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={ + "error": { + "code": "SESSION_REVOKED", + "message": "Session revoked. Please log in again.", + } + }, + ) + + try: + payload = decode_token(refresh_token_str) + except JWTError: + raise _invalid + + if payload.get("type") != "refresh": + raise _invalid + + jti_str = payload.get("jti") + user_id_str = payload.get("sub") + if not jti_str or not user_id_str: + raise _invalid + + jti = uuid.UUID(jti_str) + user_id = uuid.UUID(user_id_str) + + # Fast-check Redis first (graceful degradation if Redis is down) + if redis: + try: + is_revoked = await redis.get(f"revoked_jti:{jti}") + if is_revoked: + await _revoke_all_user_tokens(db, redis, user_id) + raise _stolen + except HTTPException: + raise + except Exception: + pass # Redis failure → fall through to DB check + + token_record = await db.get(RefreshToken, jti) + if not token_record: + raise _invalid + if token_record.revoked: + await _revoke_all_user_tokens(db, redis, user_id) + raise _stolen + + # Valid token — rotate: mark old JTI revoked + token_record.revoked = True + await db.flush() + + # Store old JTI in Redis with remaining TTL for fast rejection + if redis: + try: + remaining = int( + ( + token_record.expires_at.replace(tzinfo=UTC) - datetime.now(UTC) + ).total_seconds() + ) + if remaining > 0: + await redis.setex(f"revoked_jti:{jti}", remaining, "1") + except Exception: + pass + + user = await db.get(User, user_id) + if not user or not user.is_active: + raise _invalid + + access_token, new_refresh_token = await _issue_tokens(db, user_id) + await db.commit() + await db.refresh(user) + return user, access_token, new_refresh_token + + +async def _revoke_all_user_tokens(db: AsyncSession, redis, user_id: uuid.UUID) -> None: + from sqlalchemy import update + + await db.execute( + update(RefreshToken) + .where(RefreshToken.user_id == user_id, RefreshToken.revoked.is_(False)) + .values(revoked=True) + ) + await db.commit() + + +async def google_login(db: AsyncSession, code: str) -> tuple[User, str, str]: + from app.services.google_oauth import exchange_code_for_profile, upsert_google_user + + profile = await exchange_code_for_profile(code) + user = await upsert_google_user(db, profile) + access_token, refresh_token = await _issue_tokens(db, user.id) + await db.commit() + await db.refresh(user) + return user, access_token, refresh_token diff --git a/apps/api/app/services/google_oauth.py b/apps/api/app/services/google_oauth.py new file mode 100644 index 0000000..9ebc2dd --- /dev/null +++ b/apps/api/app/services/google_oauth.py @@ -0,0 +1,79 @@ +import urllib.parse + +import httpx +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.settings import settings +from app.models.user import User + +_GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" +_GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" +_GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo" + + +def get_google_auth_url() -> str: + params = { + "client_id": settings.GOOGLE_CLIENT_ID, + "redirect_uri": settings.GOOGLE_REDIRECT_URI, + "response_type": "code", + "scope": "openid email profile", + "access_type": "offline", + } + return _GOOGLE_AUTH_URL + "?" + urllib.parse.urlencode(params) + + +async def exchange_code_for_profile(code: str) -> dict: + async with httpx.AsyncClient(timeout=10) as client: + token_resp = await client.post( + _GOOGLE_TOKEN_URL, + data={ + "code": code, + "client_id": settings.GOOGLE_CLIENT_ID, + "client_secret": settings.GOOGLE_CLIENT_SECRET, + "redirect_uri": settings.GOOGLE_REDIRECT_URI, + "grant_type": "authorization_code", + }, + ) + token_resp.raise_for_status() + tokens = token_resp.json() + + userinfo_resp = await client.get( + _GOOGLE_USERINFO_URL, + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + ) + userinfo_resp.raise_for_status() + return userinfo_resp.json() + + +async def upsert_google_user(db: AsyncSession, profile: dict) -> User: + google_id: str = profile["sub"] + email: str | None = profile.get("email") + display_name: str | None = profile.get("name") + avatar_url: str | None = profile.get("picture") + + # 1. Match by google_id — returning user who already linked Google + user = await db.scalar(select(User).where(User.google_id == google_id)) + if user: + return user + + # 2. Match by email — existing email/password account; link google_id to it + if email: + user = await db.scalar(select(User).where(User.email == email)) + if user: + user.google_id = google_id + if not user.avatar_url and avatar_url: + user.avatar_url = avatar_url + await db.flush() + return user + + # 3. No match — create a brand-new Google-only account + user = User( + email=email, + google_id=google_id, + display_name=display_name, + avatar_url=avatar_url, + ) + db.add(user) + await db.flush() + return user diff --git a/apps/api/migrations/versions/6fc915cac3e1_initial_schema.py b/apps/api/migrations/versions/6fc915cac3e1_initial_schema.py new file mode 100644 index 0000000..8d27532 --- /dev/null +++ b/apps/api/migrations/versions/6fc915cac3e1_initial_schema.py @@ -0,0 +1,134 @@ +"""initial_schema + +Revision ID: 6fc915cac3e1 +Revises: +Create Date: 2026-04-30 17:43:34.656564 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = '6fc915cac3e1' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('email', sa.String(), nullable=False), + sa.Column('username', sa.String(), nullable=True), + sa.Column('display_name', sa.String(), nullable=True), + sa.Column('avatar_url', sa.String(), nullable=True), + sa.Column('password_hash', sa.String(), nullable=True), + sa.Column('google_id', sa.String(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_index( + 'ix_users_google_id', 'users', ['google_id'], unique=True, + postgresql_where=sa.text('google_id IS NOT NULL'), + ) + op.create_table('refresh_tokens', + sa.Column('jti', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('expires_at', sa.TIMESTAMP(timezone=True), nullable=False), + sa.Column('revoked', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('jti') + ) + op.create_index(op.f('ix_refresh_tokens_expires_at'), 'refresh_tokens', ['expires_at'], unique=False) + op.create_index(op.f('ix_refresh_tokens_user_id'), 'refresh_tokens', ['user_id'], unique=False) + op.create_table('wishlists', + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('title', sa.String(), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('cover_url', sa.String(), nullable=True), + sa.Column('event_type', sa.Enum('birthday', 'wedding', 'new_year', 'other', name='eventtype', native_enum=False), nullable=True), + sa.Column('is_public', sa.Boolean(), nullable=False), + sa.Column('slug', sa.String(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_wishlists_slug'), 'wishlists', ['slug'], unique=True) + op.create_index(op.f('ix_wishlists_user_id'), 'wishlists', ['user_id'], unique=False) + op.create_table('saved_wishlists', + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('wishlist_id', sa.UUID(), nullable=False), + sa.Column('saved_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['wishlist_id'], ['wishlists.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('user_id', 'wishlist_id') + ) + op.create_table('wish_items', + sa.Column('wishlist_id', sa.UUID(), nullable=False), + sa.Column('title', sa.String(), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('image_url', sa.String(), nullable=True), + sa.Column('price', sa.Numeric(precision=12, scale=2), nullable=True), + sa.Column('currency', sa.String(length=3), nullable=False), + sa.Column('product_url', sa.String(), nullable=True), + sa.Column('priority', sa.SmallInteger(), nullable=False), + sa.Column('is_surprise', sa.Boolean(), nullable=False), + sa.Column('position', sa.Integer(), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['wishlist_id'], ['wishlists.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_wish_items_wishlist_id'), 'wish_items', ['wishlist_id'], unique=False) + op.create_table('reservations', + sa.Column('item_id', sa.UUID(), nullable=False), + sa.Column('reserver_id', sa.UUID(), nullable=True), + sa.Column('reserver_name', sa.String(), nullable=True), + sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('id', sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(['item_id'], ['wish_items.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['reserver_id'], ['users.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('item_id') + ) + op.create_index(op.f('ix_reservations_reserver_id'), 'reservations', ['reserver_id'], unique=False) + op.create_table('saved_items', + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('item_id', sa.UUID(), nullable=False), + sa.Column('saved_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['item_id'], ['wish_items.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('user_id', 'item_id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('saved_items') + op.drop_index(op.f('ix_reservations_reserver_id'), table_name='reservations') + op.drop_table('reservations') + op.drop_index(op.f('ix_wish_items_wishlist_id'), table_name='wish_items') + op.drop_table('wish_items') + op.drop_table('saved_wishlists') + op.drop_index(op.f('ix_wishlists_user_id'), table_name='wishlists') + op.drop_index(op.f('ix_wishlists_slug'), table_name='wishlists') + op.drop_table('wishlists') + op.drop_index(op.f('ix_refresh_tokens_user_id'), table_name='refresh_tokens') + op.drop_index(op.f('ix_refresh_tokens_expires_at'), table_name='refresh_tokens') + op.drop_table('refresh_tokens') + op.drop_index(op.f('ix_users_google_id'), table_name='users') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + # ### end Alembic commands ### diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index 49135c6..efdd072 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -8,10 +8,10 @@ dependencies = [ "sqlalchemy[asyncio]>=2.0.0", "asyncpg>=0.30.0", "alembic>=1.14.0", - "pydantic>=2.10.0", + "pydantic[email]>=2.10.0", "pydantic-settings>=2.7.0", "python-jose[cryptography]>=3.3.0", - "passlib[bcrypt]>=1.7.4", + "bcrypt>=4.0.0", "httpx>=0.28.0", "beautifulsoup4>=4.12.0", "slowapi>=0.1.9", @@ -21,14 +21,9 @@ dependencies = [ [dependency-groups] dev = [ - "pytest>=8.3.0", - "pytest-asyncio>=0.24.0", "ruff>=0.9.0", ] -[tool.pytest.ini_options] -asyncio_mode = "auto" - [tool.ruff] line-length = 88 target-version = "py312" @@ -36,6 +31,9 @@ target-version = "py312" [tool.ruff.lint] select = ["E", "F", "I", "W", "UP"] +[tool.ruff.lint.per-file-ignores] +"app/models/*.py" = ["F821"] + [tool.hatch.build.targets.wheel] packages = ["app"] diff --git a/apps/api/tests/__init__.py b/apps/api/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/api/tests/conftest.py b/apps/api/tests/conftest.py deleted file mode 100644 index 05134c7..0000000 --- a/apps/api/tests/conftest.py +++ /dev/null @@ -1,5 +0,0 @@ -import os - -os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://test:test@localhost:5432/test") -os.environ.setdefault("SECRET_KEY", "test-secret-key-32-characters-long-xxxx") -os.environ.setdefault("REDIS_URL", "redis://localhost:6379") diff --git a/apps/api/tests/test_health.py b/apps/api/tests/test_health.py deleted file mode 100644 index f535693..0000000 --- a/apps/api/tests/test_health.py +++ /dev/null @@ -1,27 +0,0 @@ -import pytest -from httpx import ASGITransport, AsyncClient - - -@pytest.mark.asyncio -async def test_health_returns_ok(): - from app.main import app - - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - response = await client.get("/api/health") - - assert response.status_code == 200 - body = response.json() - assert body["data"]["status"] == "ok" - assert "environment" in body["data"] - - -@pytest.mark.asyncio -async def test_health_response_shape(): - from app.main import app - - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: - response = await client.get("/api/health") - - body = response.json() - assert "data" in body - assert "error" not in body diff --git a/apps/api/uv.lock b/apps/api/uv.lock index c33eb3c..0abfc32 100644 --- a/apps/api/uv.lock +++ b/apps/api/uv.lock @@ -332,6 +332,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + [[package]] name = "ecdsa" version = "0.19.2" @@ -344,6 +353,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" }, ] +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + [[package]] name = "fastapi" version = "0.136.1" @@ -482,15 +504,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, ] -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - [[package]] name = "limits" version = "5.8.0" @@ -589,29 +602,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] -[[package]] -name = "passlib" -version = "1.7.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" }, -] - -[package.optional-dependencies] -bcrypt = [ - { name = "bcrypt" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - [[package]] name = "pyasn1" version = "0.6.3" @@ -645,6 +635,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, ] +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + [[package]] name = "pydantic-core" version = "2.46.3" @@ -734,44 +729,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" }, ] -[[package]] -name = "pygments" -version = "2.20.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, -] - -[[package]] -name = "pytest" -version = "9.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, -] - -[[package]] -name = "pytest-asyncio" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, -] - [[package]] name = "python-dotenv" version = "1.2.2" @@ -1194,12 +1151,12 @@ source = { editable = "." } dependencies = [ { name = "alembic" }, { name = "asyncpg" }, + { name = "bcrypt" }, { name = "beautifulsoup4" }, { name = "cloudinary" }, { name = "fastapi" }, { name = "httpx" }, - { name = "passlib", extra = ["bcrypt"] }, - { name = "pydantic" }, + { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, { name = "python-jose", extra = ["cryptography"] }, { name = "redis" }, @@ -1210,8 +1167,6 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "pytest" }, - { name = "pytest-asyncio" }, { name = "ruff" }, ] @@ -1219,12 +1174,12 @@ dev = [ requires-dist = [ { name = "alembic", specifier = ">=1.14.0" }, { name = "asyncpg", specifier = ">=0.30.0" }, + { name = "bcrypt", specifier = ">=4.0.0" }, { name = "beautifulsoup4", specifier = ">=4.12.0" }, { name = "cloudinary", specifier = ">=1.41.0" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "httpx", specifier = ">=0.28.0" }, - { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, - { name = "pydantic", specifier = ">=2.10.0" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.10.0" }, { name = "pydantic-settings", specifier = ">=2.7.0" }, { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" }, { name = "redis", extras = ["asyncio"], specifier = ">=5.2.0" }, @@ -1234,11 +1189,7 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [ - { name = "pytest", specifier = ">=8.3.0" }, - { name = "pytest-asyncio", specifier = ">=0.24.0" }, - { name = "ruff", specifier = ">=0.9.0" }, -] +dev = [{ name = "ruff", specifier = ">=0.9.0" }] [[package]] name = "wrapt" diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..c85b20b --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,165 @@ +# Wishpicks Web + +Nuxt 3 frontend for the Wishpicks wishlist platform. + +## Stack + +| Concern | Technology | +|---|---| +| Framework | Nuxt 3 | +| UI library | @nuxt/ui v3 (Tailwind-based) | +| State management | Pinia (@pinia/nuxt) | +| i18n | @nuxtjs/i18n v9 | +| Language | TypeScript | +| Linting | ESLint (@nuxt/eslint) | +| Formatting | Prettier + prettier-plugin-tailwindcss | + +## Local development + +All services run through Docker Compose from the repo root: + +```bash +# Start everything (Postgres + Redis + API + Web) +docker-compose up + +# Frontend is available at http://localhost:3000 +# Nuxt devtools at http://localhost:3000/_nuxt/ +``` + +The web container mounts `./apps/web` as a volume and runs Nuxt with `--host 0.0.0.0`, so file changes trigger HMR immediately without rebuilding. + +Or run locally (Node 20+ required): + +```bash +cd apps/web +npm install +npm run dev # http://localhost:3000 +``` + +## Environment variables + +Set via `.env` at the repo root. Nuxt reads `NUXT_PUBLIC_*` automatically: + +| Variable | Maps to | Description | +|---|---|---| +| `NUXT_PUBLIC_API_BASE_URL` | `runtimeConfig.public.apiBaseUrl` | Backend base URL, e.g. `http://localhost:8000` | +| `NUXT_PUBLIC_CLOUDINARY_CLOUD_NAME` | `runtimeConfig.public.cloudinaryCloudName` | Cloudinary cloud name for image uploads | + +Access in code: + +```ts +const { public: { apiBaseUrl } } = useRuntimeConfig() +``` + +## Project structure + +``` +app.vue # Root component — wraps in layout +nuxt.config.ts # Nuxt config — modules, i18n, runtimeConfig, Vite settings +pages/ +├── index.vue # Landing page +├── login.vue # Email/password login +├── register.vue # Registration +├── dashboard.vue # Authenticated home +├── profile.vue # User profile settings +├── saved.vue # Saved wishlists and items +├── wishlists/ +│ ├── new.vue # Create wishlist +│ ├── [id]/index.vue # Wishlist detail (owner view) +│ └── [id]/settings.vue # Wishlist settings +├── w/[slug].vue # Public shared wishlist (guest view) +└── auth/google/callback.vue # Google OAuth redirect handler +components/ +├── ui/ +│ └── LanguageSwitcher.vue # UK / EN toggle +├── auth/ # Auth form components (to be built) +├── items/ # Wish item components (to be built) +└── wishlist/ # Wishlist card / list components (to be built) +composables/ +├── useAuth.ts # User state composable — wraps store + handles 401 → refresh → retry +└── api/ + └── useAuth.ts # Raw API calls: register, login, logout, refresh, me +stores/ +├── useAuthStore.ts # Current user + isAuthenticated getter +├── useWishlistStore.ts # Wishlists CRUD state +├── useItemStore.ts # Wish items state +└── useSavedStore.ts # Saved wishlists and items state +locales/ +├── uk.json # Ukrainian (default locale) +└── en.json # English +plugins/ +└── i18n-messages.ts # Registers locale messages at runtime +i18n.config.ts # i18n module config +``` + +## i18n + +Ukrainian is the default locale. URLs are unprefixed for Ukrainian (`/dashboard`) and prefixed for English (`/en/dashboard`). + +```ts +// In any component +const { t } = useI18n() +// t('auth.login') → reads from locales/uk.json or locales/en.json +``` + +All user-facing strings **must** go in `locales/uk.json` and `locales/en.json`. No hardcoded text in `.vue` files. + +## API calls + +**Never call `$fetch` directly from a component.** All API calls go through composables: + +``` +composables/api/useAuth.ts → wraps fetch for /api/auth/* endpoints +composables/api/useWishlists.ts → wraps fetch for /api/wishlists/* (to be built) +composables/api/useItems.ts → wraps fetch for /api/items/* (to be built) +``` + +The composable layer owns error parsing, response unwrapping, and the 401 → token refresh → retry flow. Stores consume composables; components consume stores. + +Auth cookies are HTTP-only and managed by the browser — no token handling in frontend JS. + +## State management rules + +- One Pinia store per domain: `useAuthStore`, `useWishlistStore`, `useItemStore`, `useSavedStore` +- `useAuth` composable is the only place that reads/writes `useAuthStore` +- Components call composable methods, not store actions directly + +## Linting and formatting + +```bash +# Inside the web container +docker exec wishpicks-web-1 npm run lint +docker exec wishpicks-web-1 npm run lint:fix +docker exec wishpicks-web-1 npm run format + +# Or locally +cd apps/web +npm run lint +npm run lint:fix +npm run format +``` + +ESLint config: `eslint.config.mjs` (flat config, @nuxt/eslint + eslint-config-prettier) +Prettier config: `.prettierrc` + +## Building for production + +```bash +docker-compose up -d --build web +``` + +Required when `package.json` dependencies change. For code-only changes the volume mount handles it. + +```bash +# Standalone production build (outside Docker) +cd apps/web +npm run build +npm run preview # serves the built output locally +``` + +## Auth flow notes + +- Tokens live in HTTP-only cookies set by the API — the frontend never reads or stores them +- On 401 responses the `useAuth` composable should attempt one silent token refresh (`POST /api/auth/refresh`), then retry the original request +- If the refresh also fails the user is considered logged out — clear store state and redirect to `/login` +- The `optional_current_user` pattern on shared wishlist pages (`/w/[slug]`) means the API returns public data whether or not the user is authenticated; the frontend should pass cookies regardless so the API can personalize the response when a session exists diff --git a/apps/web/app.config.ts b/apps/web/app.config.ts new file mode 100644 index 0000000..da45b0c --- /dev/null +++ b/apps/web/app.config.ts @@ -0,0 +1,61 @@ +export default defineAppConfig({ + ui: { + colors: { + primary: 'neutral', + }, + + button: { + slots: { + base: [ + 'rounded-full', + 'font-medium', + 'cursor-pointer', + 'inline-flex items-center justify-center', + 'transition-all', + 'active:scale-[0.96]', + 'focus-visible:outline-none', + ], + }, + defaultVariants: { + color: 'neutral', + variant: 'solid', + size: 'md', + }, + }, + + card: { + slots: { + root: [ + 'bg-white dark:bg-black', + 'rounded-xl', + 'shadow-card', + 'ring-0 border-0', + 'overflow-hidden', + ], + body: 'p-6', + header: 'px-6 pt-6', + footer: 'px-6 pb-6', + }, + }, + + input: { + slots: { + base: [ + 'rounded-lg', + 'border border-black dark:border-white', + 'bg-white dark:bg-black', + 'text-black dark:text-white', + 'placeholder:text-muted-gray', + 'focus:outline-none focus:ring-0', + 'w-full', + ], + }, + }, + + formField: { + slots: { + label: 'text-sm font-medium text-black dark:text-white mb-1', + }, + }, + }, +}) diff --git a/apps/web/app.vue b/apps/web/app.vue index 8dd09d3..794c365 100644 --- a/apps/web/app.vue +++ b/apps/web/app.vue @@ -1,5 +1,11 @@ + + diff --git a/apps/web/assets/css/main.css b/apps/web/assets/css/main.css new file mode 100644 index 0000000..3c68c62 --- /dev/null +++ b/apps/web/assets/css/main.css @@ -0,0 +1,71 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,400;0,500;0,700;1,400&display=swap'); + +@import 'tailwindcss'; +@import '@nuxt/ui'; + +@theme { + --color-brand-black: #121212; + --color-brand-white: #ffffff; + --color-body-gray: #4b4b4b; + --color-muted-gray: #afafaf; + --color-chip-gray: #efefef; + --color-hover-gray: #e2e2e2; + --color-hover-light: #f3f3f3; + + --font-heading: + 'Inter', system-ui, 'Helvetica Neue', Helvetica, Arial, sans-serif; + --font-body: + 'Inter', system-ui, 'Helvetica Neue', Helvetica, Arial, sans-serif; + + --shadow-card: 0px 4px 16px 0px rgba(0, 0, 0, 0.12); + --shadow-medium: 0px 4px 16px 0px rgba(0, 0, 0, 0.16); + --shadow-floating: 0px 2px 8px 0px rgba(0, 0, 0, 0.16); +} + +@layer base { + :root { + --wp-surface: #ffffff; + --wp-on-surface: #121212; + --wp-border: #121212; + --wp-muted: #4b4b4b; + + font-family: var(--font-body); + -webkit-font-smoothing: antialiased; + } + + .dark { + --wp-surface: #121212; + --wp-on-surface: #ffffff; + --wp-border: #ffffff; + --wp-muted: #afafaf; + } +} + +@keyframes wp-spin-in { + from { opacity: 0; transform: rotate(-90deg) scale(0.5); } + to { opacity: 1; transform: rotate(0deg) scale(1); } +} + +@keyframes wp-spin-out { + from { opacity: 1; transform: rotate(0deg) scale(1); } + to { opacity: 0; transform: rotate(90deg) scale(0.5); } +} + +.wp-spinner-enter-active, +.wp-spinner-leave-active { + will-change: transform, opacity; +} + +.wp-spinner-enter-active { + animation: wp-spin-in 0.45s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +.wp-spinner-leave-active { + animation: wp-spin-out 0.3s cubic-bezier(0.22, 1, 0.36, 1) forwards; +} + +:root { + --ui-color-neutral-950: #121212; + --ui-color-neutral-900: #111111; + --ui-color-neutral-50: #f9f9f9; +} diff --git a/apps/web/components/U/Button.vue b/apps/web/components/U/Button.vue new file mode 100644 index 0000000..aed47da --- /dev/null +++ b/apps/web/components/U/Button.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/apps/web/components/landing/CtaSection.vue b/apps/web/components/landing/CtaSection.vue new file mode 100644 index 0000000..ad56cf4 --- /dev/null +++ b/apps/web/components/landing/CtaSection.vue @@ -0,0 +1,55 @@ + + + + + \ No newline at end of file diff --git a/apps/web/components/landing/FeaturesSection.vue b/apps/web/components/landing/FeaturesSection.vue new file mode 100644 index 0000000..c1e0dbe --- /dev/null +++ b/apps/web/components/landing/FeaturesSection.vue @@ -0,0 +1,51 @@ + + + + + \ No newline at end of file diff --git a/apps/web/components/landing/HeroSection.vue b/apps/web/components/landing/HeroSection.vue new file mode 100644 index 0000000..641abd2 --- /dev/null +++ b/apps/web/components/landing/HeroSection.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/apps/web/components/landing/PrivacySection.vue b/apps/web/components/landing/PrivacySection.vue new file mode 100644 index 0000000..e8ed410 --- /dev/null +++ b/apps/web/components/landing/PrivacySection.vue @@ -0,0 +1,63 @@ + + + + + \ No newline at end of file diff --git a/apps/web/components/landing/StepsSection.vue b/apps/web/components/landing/StepsSection.vue new file mode 100644 index 0000000..1732129 --- /dev/null +++ b/apps/web/components/landing/StepsSection.vue @@ -0,0 +1,46 @@ + + + + + \ No newline at end of file diff --git a/apps/web/components/landing/WishlistPreview.vue b/apps/web/components/landing/WishlistPreview.vue new file mode 100644 index 0000000..3da2cbf --- /dev/null +++ b/apps/web/components/landing/WishlistPreview.vue @@ -0,0 +1,97 @@ + + + + + \ No newline at end of file diff --git a/apps/web/components/ui/LanguageSwitcher.vue b/apps/web/components/ui/LanguageSwitcher.vue index bb463a8..3665c71 100644 --- a/apps/web/components/ui/LanguageSwitcher.vue +++ b/apps/web/components/ui/LanguageSwitcher.vue @@ -3,7 +3,7 @@