Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.git
.venv
__pycache__
*.pyc
.pytest_cache
backend/.venv
backend/data
frontend/node_modules
frontend/.next
frontend/out
frontend/test-results
docs
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
OPENROUTER_API_KEY=
108 changes: 108 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
backend:
name: Backend tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v5
with:
version: "latest"
python-version: "3.12"

- name: Install dependencies
run: uv sync --locked

- name: Run pytest
# OPENROUTER_API_KEY is intentionally not set here: the handful of
# tests that make a real OpenRouter call skip themselves without it
# (see backend/tests/test_ai.py, test_chat.py).
run: uv run pytest

frontend:
name: Frontend unit tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json

- name: Install dependencies
run: npm ci

- name: Lint
run: npm run lint

- name: Unit tests
run: npm run test:unit

e2e:
name: Frontend e2e tests (Playwright)
runs-on: ubuntu-latest
needs: [backend, frontend]
# AI-driven scenarios (e.g. chat.spec.ts) call the real OpenRouter API
# and are known to fail without a working OPENROUTER_API_KEY secret
# configured on this repo (see docs/PLAN.md, Parts 8-10). Non-AI specs
# (auth, kanban) still exercise the real backend + frontend end to end,
# so this job stays informational rather than blocking merges on an
# external dependency outside the PR's control.
continue-on-error: true
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v5
with:
version: "latest"
python-version: "3.12"

- name: Install backend dependencies
working-directory: backend
run: uv sync --locked

- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json

- name: Install frontend dependencies
run: npm ci

- name: Install Playwright browsers
run: npx playwright install --with-deps chromium

- name: Run e2e tests
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
run: npm run test:e2e

- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: frontend/playwright-report/
retention-days: 7
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,10 @@ cython_debug/

.DS_Store

# Project-specific
*.db
*.db-journal
backend/data/
frontend/out/


58 changes: 58 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this is

A Project Management MVP: single-user (hardcoded `user`/`password`) Kanban board with an AI chat sidebar that can create/edit/move cards. NextJS frontend statically exported and served by a FastAPI backend, everything packaged into one Docker container. See [AGENTS.md](AGENTS.md) for the business requirements and [docs/PLAN.md](docs/PLAN.md) for what's implemented per part.

Detailed, up-to-date conventions live in per-directory `AGENTS.md` files — read them before working in that area:
- [backend/AGENTS.md](backend/AGENTS.md) — FastAPI structure, auth, db, AI/chat internals
- [frontend/AGENTS.md](frontend/AGENTS.md) — Next.js structure, data model, state/drag-and-drop, test gotchas
- [docs/DATABASE.md](docs/DATABASE.md) — SQLite schema and seeding

## Commands

Backend (from `backend/`, uses `uv`):
```
uv sync # install deps
uv run uvicorn app.main:app --reload # run dev server (port 8000)
uv run pytest # run all tests
uv run pytest tests/test_board.py -k test_name # run a single test
```

Frontend (from `frontend/`):
```
npm run dev # dev server, port 3000 (proxies /api/* to backend on 8000)
npm run build # static export (output: "export")
npm run lint
npm run test:unit # Vitest (or: npm run test)
npm run test:unit:watch
npm run test:e2e # Playwright e2e; starts backend + next dev itself
npm run test:all # unit + e2e
```
Single Vitest file: `npx vitest run src/lib/kanban.test.ts`. Single Playwright spec: `npx playwright test tests/auth.spec.ts`.

Full stack via Docker (from repo root):
```
scripts/start.sh # or start.ps1 on Windows — docker compose up -d --build, app at :8000
scripts/stop.sh # or stop.ps1
```

CI (`.github/workflows/`) runs backend pytest, frontend lint + unit tests, and a non-blocking e2e job on every push/PR to `main`.

## Architecture

- FastAPI serves both the API (`/api/*`) and the pre-built Next.js static export (everything else), via a custom `NextStaticFiles` class in `backend/app/main.py` that resolves routes like `/login` to their exported `login.html`.
- Frontend is a static export (no Next server-side features); local dev (`next dev`) proxies `/api/*` to a locally-running backend since there's no server to answer those routes itself.
- Data model is normalized and shared in shape across three layers: SQLite (`columns`/`cards` tables) → backend pydantic models (`backend/app/board.py`) → frontend TS types (`frontend/src/lib/kanban.ts`) — columns hold ordered `cardIds`, cards are keyed by id.
- Auth is a single hardcoded credential pair; sessions and (per Part 9) AI chat history are both in-memory dicts/sets on the backend process, not persisted — acceptable for this MVP, cleared on restart/logout.
- AI chat (`backend/app/chat.py`, `backend/app/ai.py`) calls OpenRouter (`openai/gpt-oss-120b`) requesting structured JSON output (`{reply, board_update}`); a returned `board_update` is a full-board replacement, validated for internal consistency, then persisted via `replace_board` — not a diff/patch.
- `backend/app/mcp_server.py` (untracked/in progress) exposes the board as MCP tools over stdio, talking to `app.board`/`app.db` directly rather than through the HTTP API.

## Coding standards (from AGENTS.md)

- Use latest versions of libraries and idiomatic approaches.
- Keep it simple — no over-engineering, no unnecessary defensive programming, no speculative features.
- Be concise; no emojis, ever.
- When hitting issues, find root cause with evidence before fixing — don't guess.
26 changes: 26 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
FROM node:22-bookworm-slim AS frontend-build

WORKDIR /app/frontend

COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci

COPY frontend/ ./
RUN npm run build

FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim

WORKDIR /app

COPY backend/pyproject.toml backend/uv.lock ./
RUN uv sync --locked --no-install-project --no-dev

COPY backend/app ./app
COPY --from=frontend-build /app/frontend/out ./static

ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
1 change: 1 addition & 0 deletions backend/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12
40 changes: 39 additions & 1 deletion backend/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1 +1,39 @@
This file should be updated with a description of the Backend
# Backend

FastAPI backend, managed with `uv`. Serves the API and (from Part 3 onward) the built frontend.

## Structure

```text
app/
main.py FastAPI app; lifespan runs db.init_db(); NextStaticFiles mount; all routes
auth.py Hardcoded credential check, in-memory session store, require_session dependency
db.py SQLite schema (DDL), seeding from the frontend's initialData, get_db per-request dependency
board.py BoardData/Column/Card pydantic models + CRUD functions operating on a sqlite3.Connection
ai.py OpenRouter client (complete/complete_structured); loads root .env locally, reads OPENROUTER_API_KEY
chat.py /api/chat's logic: system prompt, response JSON schema, per-session history, board_update parsing/validation
static/ Static files served at "/" (placeholder index.html until Part 3 replaces it with the built frontend)
tests/
test_health.py pytest + FastAPI TestClient
test_static_routing.py NextStaticFiles path resolution, incl. the login.html/login/ directory collision
test_auth.py login/logout/session routes, require_session dependency
test_board.py board CRUD routes, incl. persistence across a fresh connection to the same db file
test_ai.py OpenRouter client error handling (mocked httpx) + one real call, skipped if no API key
test_chat.py board_update validation, /api/chat route (mocked OpenRouter call) + 2 real-call tests, skipped if no API key
```

## Conventions

- Routes live under `/api/*`; everything else falls through to `NextStaticFiles` (mounted last, `html=True`) so `/` serves `static/index.html` and other routes resolve to their pre-rendered `<path>.html` — see the class docstring in `main.py` for why a plain `StaticFiles` isn't enough (Next 16's static export writes a same-named directory alongside each route's HTML file).
- Auth (`auth.py`): single hardcoded user (`user`/`password`), session tokens are random strings held in an in-memory `set` — cleared on process restart, which is fine for this MVP. `require_session` gates the board routes via `dependencies=[Depends(require_session)]`. The session cookie is HttpOnly; static/page serving is intentionally *not* gated server-side (static export has no per-request logic) — the frontend's `AuthGate` component checks `/api/session` and redirects client-side instead.
- Database (`db.py`): SQLite file at `DATABASE_PATH` env var (default `data/pm.db`, relative to cwd — resolves to `/app/data/pm.db` in Docker, matching the `db-data` volume in `docker-compose.yml`; resolves to `backend/data/pm.db` for local dev, gitignored). Schema/seed run once per process via `init_db()` in `main.py`'s `lifespan`. Read the path lazily (not a module-level constant) so tests can point `DATABASE_PATH` at a `tmp_path` file before the app's lifespan runs. `get_db()` opens and closes a fresh connection per request — simplest correct approach for SQLite, no shared-connection threading concerns.
- Board (`board.py`): `users`/`boards` tables exist for the FK relationship and future multi-user support, but auth and board lookup don't use them for identity — see `docs/DATABASE.md` and `_get_board_id`'s comment. Every mutating function takes a `sqlite3.Connection` and commits internally; routes in `main.py` catch `ColumnNotFound`/`CardNotFound`/`BoardNotFound` and turn them into 404s.
- AI (`ai.py`): `complete(messages)` and `complete_structured(messages, schema_name, schema)` both POST to OpenRouter (`openai/gpt-oss-120b`) via a shared `_post_chat_completion`/`_extract_content` pair — `complete_structured` adds a `response_format: {type: "json_schema", json_schema: {strict: true, ...}}` for Part 9's chat route. Both raise `OpenRouterError` for a missing key, network failure, non-200, or an unexpected response shape — routes map that to a 502. `load_dotenv()` runs at import time pointed at the repo-root `.env` (`parents[2]` from `app/ai.py`) since local `uv run` has `cwd=backend/`; in Docker, `OPENROUTER_API_KEY` is already injected via `docker-compose.yml`'s `env_file`, so the (absent) `.env` load there is just a no-op.
- Chat (`chat.py`): `/api/chat` always sends the current board (as JSON, `cards` as an **array** — see below) plus per-session history plus the new message, requesting structured output matching `RESPONSE_SCHEMA` (`{reply, board_update}`). `board_update`, if present, is a **full-board replacement** (not a diff/patch) — `parse_board_update` converts its array-of-cards wire shape into the internal dict-keyed `BoardData`, then `_validate_board_update` checks internal consistency (unique column ids, each card referenced by exactly one column, no dangling/orphaned ids) before `board.py`'s `replace_board` (delete-and-reinsert) persists it. An invalid `board_update` is silently ignored — the reply still comes back, the board just doesn't change. `cards` must be an array in the wire schema (not the dict-by-id shape used elsewhere) because strict-mode JSON Schema structured outputs don't support a dynamic-keyed object. Conversation history lives in an in-memory `dict[session_id, list[message]]`, cleared on logout — same "in-memory is fine for this MVP" call as sessions themselves (Part 5).
- Dependencies split into runtime (`dependencies`) and dev-only (`dependency-groups.dev`, currently just `pytest`) in `pyproject.toml`. `httpx` is a runtime dependency (used by both `ai.py` and `TestClient`), not dev-only, despite mostly showing up in tests.
- Run locally: `uv sync`, `uv run uvicorn app.main:app --reload`, `uv run pytest`.
- The Docker image (root `Dockerfile`) installs deps with `uv sync --locked --no-dev` — keep `uv.lock` committed and up to date via `uv add`/`uv remove`, not manual edits.
- For local e2e testing, `frontend/playwright.config.ts` runs this backend on port 8000 (`uv run --directory ../backend uvicorn ...`) alongside `next dev` on port 3000, which proxies `/api/*` to it (see `frontend/next.config.ts`).
- Tests that hit the database must use `with TestClient(app) as client:` (not a bare `TestClient(app)`) — only the context-manager form runs the FastAPI `lifespan`, which is what creates/seeds the SQLite schema. Tests that don't touch the DB (health/auth/static routing) intentionally use the bare form so they don't trigger it.

See [../docs/PLAN.md](../docs/PLAN.md) for what's implemented per part and [../docs/DATABASE.md](../docs/DATABASE.md) for the schema.
11 changes: 11 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Backend

FastAPI backend for the PM Kanban app. See [AGENTS.md](AGENTS.md) and [../docs/PLAN.md](../docs/PLAN.md).

## Development

```
uv sync
uv run uvicorn app.main:app --reload
uv run pytest
```
Empty file added backend/app/__init__.py
Empty file.
75 changes: 75 additions & 0 deletions backend/app/ai.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import os
from pathlib import Path
from typing import Any

import httpx
from dotenv import load_dotenv

# In Docker, OPENROUTER_API_KEY is already injected via docker-compose's
# env_file. For local `uv run` (cwd is backend/), load the root .env
# explicitly; a missing file here is a no-op, not an error.
load_dotenv(Path(__file__).resolve().parents[2] / ".env")

OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
MODEL = "openai/gpt-oss-120b"


class OpenRouterError(Exception):
pass


def _get_api_key() -> str:
api_key = os.environ.get("OPENROUTER_API_KEY")
if not api_key:
raise OpenRouterError("OPENROUTER_API_KEY is not set")
return api_key


def _post_chat_completion(
messages: list[dict[str, str]], response_format: dict[str, Any] | None = None
) -> dict[str, Any]:
api_key = _get_api_key()
payload: dict[str, Any] = {"model": MODEL, "messages": messages}
if response_format is not None:
payload["response_format"] = response_format

try:
response = httpx.post(
OPENROUTER_URL,
headers={"Authorization": f"Bearer {api_key}"},
json=payload,
timeout=30.0,
)
except httpx.HTTPError as exc:
raise OpenRouterError(f"Network error calling OpenRouter: {exc}") from exc

if response.status_code != 200:
raise OpenRouterError(
f"OpenRouter request failed with status {response.status_code}: {response.text}"
)

return response.json()


def _extract_content(data: dict[str, Any]) -> str:
try:
return data["choices"][0]["message"]["content"]
except (KeyError, IndexError) as exc:
raise OpenRouterError(f"Unexpected OpenRouter response shape: {data}") from exc


def complete(messages: list[dict[str, str]]) -> str:
return _extract_content(_post_chat_completion(messages))


def complete_structured(messages: list[dict[str, str]], schema_name: str, schema: dict[str, Any]) -> str:
"""Like `complete`, but requests (and returns, still as a string) a
response conforming to `schema` via OpenRouter's structured outputs."""
data = _post_chat_completion(
messages,
response_format={
"type": "json_schema",
"json_schema": {"name": schema_name, "strict": True, "schema": schema},
},
)
return _extract_content(data)
35 changes: 35 additions & 0 deletions backend/app/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import secrets
from typing import Annotated

from fastapi import Cookie, HTTPException, status

USERNAME = "user"
PASSWORD = "password"
SESSION_COOKIE = "session_id"

_sessions: set[str] = set()


def verify_credentials(username: str, password: str) -> bool:
return username == USERNAME and password == PASSWORD


def create_session() -> str:
token = secrets.token_urlsafe(32)
_sessions.add(token)
return token


def destroy_session(token: str | None) -> None:
if token:
_sessions.discard(token)


def is_valid_session(token: str | None) -> bool:
return token is not None and token in _sessions


def require_session(session_id: Annotated[str | None, Cookie()] = None) -> str:
if not is_valid_session(session_id):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return session_id
Loading