Initial commit: backend scaffold, auth, frontend login

- FastAPI + SQLAlchemy async + Alembic + Postgres backend
- Auth: JWT access + rotating refresh tokens, argon2, roles, audit log
- React 19 + Vite frontend: login page, protected route, auth context
- Docker Compose: postgres, redis, migrate, api, worker (placeholder), frontend/nginx

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 15:07:15 +03:00
co-authored by Claude Sonnet 5
commit 2664cb8213
71 changed files with 4999 additions and 0 deletions
+121
View File
@@ -0,0 +1,121 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
A web app for manually fiscalizing (пробивать фискальні чеки) orders from an in-house CRM. Orders come from a CRM (own API, still unbuilt — see stub pattern below), delivery status and cash-on-delivery amount come from Nova Poshta, and fiscal receipts are created via the Checkbox API. Cashiers manually trigger receipt creation from a queue of "delivered, not yet fiscalized" orders; nothing fiscal happens automatically. Single cash register today, but the schema is built for several.
The full architecture rationale (why ARQ over Celery, why CRM is behind a `Protocol`, the receipt state machine, the money-units convention, etc.) lives in the project's plan file — read it before making structural changes. If it's not present in this checkout, ask the user for it rather than re-deriving the design from scratch.
Используй краткие ответы, без лишнего кода - только ключевая информация.
## Repository layout
```
backend/ FastAPI + SQLAlchemy (async) + Alembic + Postgres — see backend/app/
frontend/ React 19 + TypeScript + Vite — see frontend/src/
docker/ nginx.frontend.conf (SPA + /api reverse proxy to the api container)
docker-compose.yml postgres, redis, migrate (one-shot), api, worker, frontend
```
Backend and frontend are independent projects with their own dependency files (`backend/pyproject.toml`, `frontend/package.json`) — always `cd` into the right one before running tooling.
## Commands
### Backend (from `backend/`)
Uses a local venv at `backend/.venv`, not a global interpreter.
```bash
.venv/Scripts/pip install -e ".[dev]" # install deps (first time / after pyproject.toml changes)
.venv/Scripts/python -m pytest -q # run all tests
.venv/Scripts/python -m pytest tests/test_security.py -q # single file
.venv/Scripts/python -m pytest tests/test_security.py::TestAccessToken::test_rejects_expired_token -q # single test
.venv/Scripts/python -m ruff check app tests # lint (must be clean — see rules below)
.venv/Scripts/alembic upgrade head # apply migrations (needs POSTGRES_HOST=localhost etc. in .env)
.venv/Scripts/alembic revision -m "..." # new migration — but see "Migrations" below, usually hand-written
.venv/Scripts/uvicorn app.main:app --reload # run API standalone
python -m app.cli bootstrap # create first admin from FIRST_ADMIN_* in .env (idempotent)
python -m app.cli gen-keys # print a fresh SECRET_KEY + ENCRYPTION_KEY
```
Tests require `SECRET_KEY` and `ENCRYPTION_KEY` env vars to be set (any value works for tests; `tests/conftest.py` sets sane defaults via `os.environ.setdefault` before `app.core.config` is imported — order matters, config is read once at import time via `get_settings()`/`lru_cache`).
Tests that touch `Settings()` directly (e.g. `test_config.py`) need `secret_key` and `encryption_key` passed explicitly since `conftest.py`'s env-var defaults don't retroactively apply to already-`lru_cache`d instances.
No live Postgres is required for most tests — the migration-vs-models consistency test (`test_migration_matches_models.py`) statically diffs `alembic/versions/0001_*.py` against `Base.metadata` by regex, without a DB connection. Only actually running `alembic upgrade head` or the app against real data needs Postgres.
### Frontend (from `frontend/`)
```bash
npm install
npm run dev # Vite dev server on :5173, proxies /api/* to http://localhost:8000 (see vite.config.ts)
npm run build # tsc -b && vite build — treat tsc errors as build failures
npm run lint # oxlint (not eslint — see .oxlintrc.json)
npm run preview
```
### Docker (from repo root)
```bash
cp .env.example .env # then fill SECRET_KEY / ENCRYPTION_KEY via `python -m app.cli gen-keys`
docker compose up -d --build
docker compose run --rm api python -m app.cli bootstrap # create first admin
docker compose logs -f api # or: migrate, worker, frontend, postgres, redis
docker compose ps # migrate should show Exited(0) — it's a one-shot job, not a bug
```
`migrate` runs `alembic upgrade head` once and exits; `api`/`worker` `depends_on: migrate: condition: service_completed_successfully`. Don't add migration logic to the `api` container's startup — with multiple replicas that would race.
`worker` currently runs a placeholder `sleep` command (real ARQ worker lands at plan stage 4 — Nova Poshta). Its Dockerfile HEALTHCHECK is explicitly disabled in `docker-compose.yml` because the placeholder doesn't serve HTTP; don't be alarmed it's not "healthy", and don't re-enable the healthcheck without giving it something to check.
## Backend architecture
- **Async everywhere.** SQLAlchemy 2.0 async ORM + `asyncpg`, one DSN (`app.core.config.Settings.database_url`) used by both the app and Alembic — no separate sync driver.
- **`app/core/`** — cross-cutting, no DB/HTTP knowledge: `config.py` (Pydantic Settings, `.env`-driven), `security.py` (argon2 passwords, JWT access tokens, refresh-token hashing), `crypto.py` (Fernet — for encrypting cash-register secrets in the DB, not yet wired to a model), `logging.py` (structlog, JSON in prod / console locally).
- **`app/db/models/`** — SQLAlchemy models. Every model file must be imported in `app/db/models/__init__.py` or Alembic autogenerate silently won't see its table.
- **`app/services/`** — business logic that touches the DB (`auth.py`, `audit.py`). Routers in `app/api/v1/` stay thin and call into these.
- **`app/api/deps.py`** — `CurrentUser`, `require_admin`/`require_cashier` deps for role gating. Roles: `admin`, `cashier`, `viewer` (`UserRole(enum.StrEnum)` — do NOT use `class X(str, enum.Enum)`, ruff's `UP042` rejects it).
### Auth model (already built, stage 2 of the plan)
- Access JWT (15 min) in memory only, on the frontend. Refresh token (7 days) is a random value; only its SHA-256 hash is stored in `refresh_tokens.token_hash` — never the raw value.
- Refresh rotates on every use (`services/auth.rotate_refresh_token`): the old row gets `revoked_at` + `replaced_by_id`, a new row is inserted. **Presenting an already-revoked refresh token is treated as theft** — it revokes every active token for that user and logs `AuditAction.TOKEN_REUSE_DETECTED`. Don't "simplify" this into a plain revoke-and-reissue without the reuse check.
- `get_current_user` re-reads the user from the DB on every request (not just from JWT claims) so deactivating a user takes effect immediately instead of waiting for the access token to expire.
### Audit log
`app/services/audit.py` — every state-changing endpoint should call `audit.record(...)` in the same DB transaction as the change it's logging, not after `commit()`. It auto-redacts a fixed set of key names (`password`, `*token`, `*secret`, `license_key`, `api_key`) — if you add a new secret-shaped field, add its key to `_REDACTED_KEYS` rather than trusting callers to remember.
### Config gotchas (both bit us for real, in production-shaped values — not hypothetical)
- **`CORS_ORIGINS`** is stored as a raw string (`cors_origins_raw`) and split in a `@property`, not as `list[str]` on the Settings field directly — pydantic-settings tries to JSON-parse complex-typed env vars before validators run, so a plain `a,b` string blows up at startup. Follow this pattern for any future comma-separated env var.
- **DSN building must percent-encode user/password** (`urllib.parse.quote(..., safe="")` in `Settings.database_url`) — `PostgresDsn.build` does not escape special characters itself, and a password containing `,`, `@`, `:`, or `/` silently corrupts the parsed port/host.
- **Alembic's `env.py` builds the async engine directly from `settings.database_url`** (`create_async_engine(...)`) instead of round-tripping through `config.set_main_option("sqlalchemy.url", ...)` / `alembic.ini`. `ConfigParser` treats `%` as an interpolation character, and a percent-encoded password (e.g. `%2C` for a comma) breaks `config.set_main_option` before migrations even start. Don't reintroduce the `alembic.ini`-based URL path.
- Docker `HEALTHCHECK`s must target `127.0.0.1`, not `localhost` — Alpine's `wget`/musl resolves `localhost` to `::1` first and does not fall back to IPv4 on connection refused (unlike `curl`, which tries all resolved addresses). A container can be fully working and still show `unhealthy` if this is wrong.
### Migrations
Hand-written, not autogenerated blindly — `alembic/versions/0001_users_and_audit.py` is deliberately explicit about column nullability, index names, and `ON DELETE` behavior, using `op.f(...)` with the naming convention defined in `app/db/base.py` (`NAMING_CONVENTION`) so constraint names are deterministic and droppable in `downgrade()`. Keep that pattern for new migrations. `tests/test_migration_matches_models.py` will fail if a migration and the models it's supposed to create drift — run it after writing a migration.
### Money and quantity units
Not yet enforced by types anywhere in the current code, but is a hard project convention for when order/receipt models land (plan stages 3+): **all money amounts are integer kopecks, all quantities are integer thousandths** (Checkbox API convention: `1 pcs = 1000`, `2.25 kg = 2250`). Never introduce `float`/`Decimal`-as-currency in new models — use `bigint`.
## Frontend architecture
- **Path alias `@/`** → `frontend/src/` (configured in both `tsconfig.app.json` `paths` and `vite.config.ts` `resolve.alias`). `tsconfig.app.json` needs `"ignoreDeprecations": "6.0"` alongside `baseUrl` — this TS version deprecates bare `baseUrl` otherwise.
- **`verbatimModuleSyntax: true`** — always use `import type { Foo }` for type-only imports, or the build fails.
- **`erasableSyntaxOnly: true`** — no TS `enum`. Use string-literal union types (see `api/types.ts` `UserRole`) or `StrEnum`-equivalents. This mirrors the backend's `UserRole` values by hand; there's no generated client yet, so keep `api/types.ts` in sync with `backend/app/schemas/*.py` manually.
- **No CORS anywhere, by design.** The frontend always calls relative `/api/v1/...` paths. In dev, Vite's `server.proxy` forwards `/api` to `http://localhost:8000`. In Docker/prod, `docker/nginx.frontend.conf` proxies `/api/` to `http://api:8000/api/` inside the compose network. If you ever need to call the API from a different origin, that's a sign something about this setup broke — don't just add CORS headers as a patch.
- **Token handling is split from React on purpose:** `api/tokenStore.ts` is a plain module-level singleton (not a React context) holding the in-memory access token and localStorage-backed refresh token, with a pub-sub `subscribeToTokens`. `api/client.ts` reads/writes it directly to do silent-refresh-and-retry on a 401 without importing React or `AuthContext` — importing `AuthContext` from `client.ts` would create an import cycle (`client → auth-context → client`). `features/auth/AuthContext.tsx` subscribes to the store for React state. Keep this separation when extending auth.
- **`oxlint`, not `eslint`.** Rule names and disable-comment syntax differ (`// oxlint-disable-next-line react/only-export-components`, not `eslint-disable-next-line react-refresh/only-export-components`). Config is `.oxlintrc.json`.
- Routing: `react-router-dom`. `features/auth/ProtectedRoute.tsx` gates authenticated-only routes via `<Outlet />`; `app/routes.tsx` is the single place routes are declared.
- Data fetching: `@tanstack/react-query`, provider set up in `app/App.tsx` with `refetchOnWindowFocus: false` (order/receipt data will be polled explicitly, not refetched on focus).
## Project status (see the plan for the full roadmap)
Stages 1–2 (scaffolding, auth/roles/audit log) and a minimal frontend shell (login page + protected placeholder dashboard) are done. Stage 3 (orders + a `CrmClient` `Protocol` with a fixture-backed `StubCrmClient`, since the real CRM API doesn't exist yet) is next. Don't build order/receipt/shipment features against a guessed CRM shape — the stub pattern exists specifically so this can proceed without the real API.