Skip to content
Merged
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
212 changes: 50 additions & 162 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -1,183 +1,71 @@
# RepTrack – Copilot Instructions
# RepTrack – Agent Instructions

RepTrack is a full-stack strength-training tracker. The client is React + Vite (TypeScript), the server is FastAPI (Python 3.14+), and the database is PostgreSQL via SQLAlchemy 2.0 + AsyncPG.
RepTrack is a full-stack strength-training tracker:

---
- `client/`: React 19 + Vite + TypeScript
- `server/`: FastAPI + SQLAlchemy 2.0 async stack
- DB: PostgreSQL (AsyncPG)

## Repository Layout
## Build, Test, and Lint Commands

```
client/ React + Vite frontend
server/ FastAPI backend
config/env/ Environment files (.env.example → .env)
config/infra/ Docker Compose for dev/prod
scripts/ Dev bootstrap (dev.sh) and API generation
```

---
### Root (monorepo)

## Commands
- Format all: `npm run format`
- Lint/typecheck all configured checks: `npm run lint`
- Server typecheck only: `npm run check:py`

### Server (`cd server`)

| Task | Command |
| -------------------------- | --------------------------------------------------------------- |
| Start dev server | `make dev` |
| Run all tests | `make test` |
| Run tests with coverage | `make cov` |
| Run a single test file | `uv run pytest app/tests/api/auth/test_login.py -v` |
| Run a single test function | `uv run pytest app/tests/api/auth/test_login.py::test_login -v` |
| Type check | `make check` |
| Generate migration | `make auto_migration msg="description"` |
| Apply migrations | `make migrate` |
| Verify migrations | `make check_migrations` |
- Dev server: `make dev`
- Typecheck: `make check`
- Test suite: `make test`
- Tests with coverage: `make cov`
- Single test file: `uv run pytest app/tests/api/auth/test_login.py -v`
- Single test function: `uv run pytest app/tests/api/auth/test_login.py::test_login -v`
- Alembic check: `make check_migrations`
- Apply migrations: `make migrate`

### Client (`cd client`)

| Task | Command |
| --------------------- | ---------------------- |
| Start dev server | `npm run dev` |
| Build | `npm run build` |
| Lint (auto-fix) | `npm run lint` |
| Regenerate API client | `npm run generate-api` |

### Root (monorepo)

| Task | Command |
| ----------------- | ------------------ |
| Format all | `npm run format` |
| Lint all | `npm run lint` |
| Type check server | `npm run check:py` |

---

## Dev Environment

```bash
cp config/env/.env.example config/env/.env # fill in values
./scripts/dev.sh # install deps + start Docker Compose
./scripts/dev.sh -s # skip install, just start
./scripts/dev.sh -o # omit client/server containers
```
- Dev server: `npm run dev`
- Build: `npm run build`
- Lint (auto-fix): `npm run lint`
- Regenerate OpenAPI client: `npm run generate-api`

Docker Compose runs postgres, adminer, migrations, server (uvicorn), and client (Vite) with hot reload.
## High-Level Architecture

---
### Server request path

## Architecture
- `server/app/__init__.py` owns app assembly (`create_app`): logging setup, global exception handlers, `/api` router mounting, Swagger UI install in non-prod-like envs, CORS wrapping.
- `server/app/main.py` just instantiates and exports `fastapi_app, app`.
- `server/app/api/router.py` composes domain routers from `server/app/api/endpoints/*.py`.
- Endpoints are intentionally thin and delegate business logic to `server/app/services/*.py`.

### Server
### Auth/session architecture across backend + frontend

- **Entry point:** `server/app/main.py` — creates the FastAPI app, registers routers, exception handlers, and lifespan
- **Routing:** `server/app/api/router.py` composes all routers under `/api`. Each domain lives in `server/app/api/endpoints/{domain}.py`
- **Services:** `server/app/services/{domain}.py` — business logic, called by endpoints. Keep endpoints thin.
- **Database models:** `server/app/models/database/{entity}.py` — SQLAlchemy 2.0 declarative models
- **Schemas:** `server/app/models/schemas/{domain}.py` — Pydantic request/response models
- **Config:** `server/app/core/config.py` — `Settings` loaded via `pydantic-settings` with `__` as nested delimiter (e.g., `DB__HOST`)
- **Auth:** HTTP-only JWT cookies (`ACCESS_JWT_KEY`, `REFRESH_JWT_KEY`). `get_current_user` and `get_current_admin` are FastAPI dependencies in `server/app/core/security.py`
- **Errors:** Custom `HTTPError` subclasses in `server/app/models/errors.py`; raise them directly (e.g., `raise InvalidCredentials()`)
- Backend auth uses HTTP-only cookies (`access_token`, `refresh_token`) from `auth` endpoints.
- JWT verification and current-user/admin dependencies are in `server/app/core/dependencies.py` and `server/app/core/security.py`.
- Frontend API client (`client/src/api/axios.ts`) queues concurrent 401s and performs one refresh-token request, then retries queued calls.
- `SessionProvider` loads `/api/users/current` to determine authenticated state; `RequireAuth` / `RequireGuest` gate routes in `client/src/AppRoutes.tsx`.

### Client
### OpenAPI client generation pipeline

- **API client:** Auto-generated from OpenAPI spec via `@hey-api/openapi-ts`. Run `npm run generate-api` after server changes. Do **not** hand-edit `src/api/`.
- **Auth guards:** `RequireAuth` / `RequireGuest` components wrap routes in `src/router/`
- **Axios interceptors:** Handle 401 auto-refresh with request queueing (`src/lib/axios.ts`)

---
- Server OpenAPI spec is generated by `scripts/generate_api.sh`.
- Client generation uses `client/openapi-ts.config.ts` with `@hey-api/openapi-ts`.
- SDK generation strategy is `byTags` + `operationId` nesting, so endpoint `operation_id` values drive generated method names.
- Generated files live in `client/src/api/generated/` and should not be hand-edited.

## Key Conventions

### Endpoints

```python
api_router = APIRouter(prefix="/auth", tags=["Auth"])

@api_router.post(
"/login",
operation_id="login", # required — used for OpenAPI client generation
status_code=status.HTTP_204_NO_CONTENT,
responses={status.HTTP_401_UNAUTHORIZED: ErrorResponseModel},
)
async def login_endpoint(
req: LoginRequest,
db: Annotated[AsyncSession, Depends(get_db)],
settings: Annotated[Settings, Depends(get_settings)],
res: Response,
):
result = await login(...) # delegate to service
res.set_cookie(...)
```

Always set `operation_id` — it drives the generated TypeScript client method name.

### Schemas

- Response schemas: `{Entity}Public` (e.g., `UserPublic`)
- Request schemas: `{Action}Request` (e.g., `LoginRequest`, `RegisterRequest`)
- Convert ORM → schema with `Model.model_validate(orm_obj, from_attributes=True)`
- Shared field type aliases (`Name`, `Username`, `Password`, `Email`) live in `server/app/models/schemas/types.py`

### Database Models

```python
class User(Base):
__tablename__ = "users"

id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
```

- Use SQLAlchemy 2.0 `Mapped` annotations throughout
- All models include `created_at` / `updated_at` with `server_default=func.now()`
- Alembic bulk updates must explicitly set `updated_at` (no ORM trigger)
- Table names: lowercase plural; index names: `ix_{table}_{column}`

### Auth in Routes

```python
# Authenticated route
async def endpoint(user: Annotated[UserPublic, Depends(get_current_user)], ...):

# Admin-only route
async def endpoint(user: Annotated[UserPublic, Depends(get_current_admin)], ...):
```

### Error Handling

Define errors as `HTTPError` subclasses; raise without arguments:

```python
class InvalidCredentials(HTTPError):
status_code = status.HTTP_401_UNAUTHORIZED
code = "invalid_credentials"
detail = "Invalid credentials"

raise InvalidCredentials()
```

### Tests

- Fixtures are in `server/app/tests/fixtures/` and registered in `server/conftest.py`
- Shared helpers go in `server/app/tests/{scope}/utilities.py`, not per-test-file
- Module-private helpers and constants are prefixed with `_`
- Tests use `AsyncClient` from `httpx` against a savepoint-wrapped test DB
- Test settings use `console` email/GitHub backends; admin credentials: `admin` / `admin@example.com` / `password`
- `RegisterRequest` rejects usernames that are email addresses (enforced by `@field_validator`)
- `get_user_by_identifier` resolves login by email OR username

---

## Configuration

Settings use Pydantic nested models with `__` as the env delimiter:

```
DB__HOST, DB__PORT, DB__NAME, DB__USER, DB__PASSWORD
JWT__SECRET_KEY, JWT__ALGORITHM, JWT__ACCESS_TOKEN_EXPIRE_MINUTES
ADMIN__USERNAME, ADMIN__EMAIL, ADMIN__PASSWORD
EMAIL__BACKEND=smtp|local|console|disabled
GH__BACKEND=api|console
```

Production requires `EMAIL__BACKEND=smtp` and `GH__BACKEND=api` (validated at startup).
- Always set explicit `operation_id` on FastAPI endpoints. Missing or unstable IDs cause churn/breakage in generated TS SDK method names.
- Route handlers keep orchestration only; core business logic belongs in `server/app/services/`.
- Error handling uses `HTTPError` subclasses in `server/app/models/errors.py`; raise typed errors directly (e.g., `raise InvalidCredentials()`), not ad-hoc `HTTPException` payloads.
- Settings come from nested env vars with `__` delimiter (see `server/app/core/config.py`, e.g. `DB__HOST`, `JWT__SECRET_KEY`). In prod-like envs, config validators enforce `EMAIL__BACKEND=smtp` and `GH__BACKEND=api`.
- SQLAlchemy models use `Mapped[...]` + `mapped_column(...)`; models include `created_at`/`updated_at` timestamps. Alembic/bulk updates must set `updated_at` explicitly.
- Client lint rules enforce wrapper usage over direct imports:
- Prefer app override components like `@/components/ui/overrides/button` instead of base shadcn paths.
- Prefer `@/` alias imports over deep relative paths.
- Prefer `@/lib/notify` over direct `toast` import from `sonner`.
- Test stack uses pytest + async fixtures with a Postgres Testcontainers instance (`postgres:18`) and savepoint-based rollback per test (`server/app/tests/fixtures/database.py`).
- Shared test helpers belong in `utilities.py` under the relevant test scope; module-private test helpers/constants are prefixed with `_`.
- Auth schema rule: usernames cannot be email-shaped strings (`RegisterRequest` validator in `server/app/models/schemas/auth.py`); login accepts username or email identifier.
1 change: 1 addition & 0 deletions AGENTS.md
24 changes: 15 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

# RepTrack

## Local Development
## Development

Copy `.env.example` to `.env` & populate variables

Expand All @@ -27,7 +27,7 @@ Start containers:
./scripts/dev.sh
```

## Local GitHub Actions Testing
## Testing GitHub Actions Workflows

Use `act` to run workflows locally for quick validation.

Expand All @@ -43,13 +43,7 @@ Run a specific job:
act -j {job-id}
```

## Database Conventions

All writes should go through SQLAlchemy

Alembic updates & bulk SQLAlchemy updates must explicitly set `updated_at`

## Shadcn Component Conventions
## Shadcn Components

shadcn adds components under `client/src/components/ui/`

Expand All @@ -58,3 +52,15 @@ To ensure custom styles & behavior survive component updates, follow these conve
- Create custom component overrides under `client/src/components/ui/overrides/`
- Import override components in app code instead of generated shadcn components
- Add ESLint rules to prevent direct imports of generated components & point to override paths

## Database

### Conventions

All writes should go through SQLAlchemy

Alembic updates & bulk SQLAlchemy updates must explicitly set `updated_at`

### Database ER Diagram

![diagram](db_erd.png 'Database ER Diagram')
5 changes: 5 additions & 0 deletions client/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ export default defineConfig([
message:
'Use Button from @/components/ui/overrides/button',
},
{
name: '@/components/ui/tooltip',
message:
'Use Tooltip from @/components/ui/overrides/tooltip',
},
{
name: 'sonner',
importNames: ['toast'],
Expand Down
4 changes: 2 additions & 2 deletions client/src/api/generated/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// This file is auto-generated by @hey-api/openapi-ts

export { AdminService, AuthService, ExercisesService, FeedbackService, HealthService, MuscleGroupsService, type Options, UserService } from './sdk.gen';
export type { AccessRequestPublic, AccessRequestStatus, ClientOptions, CreateExerciseData, CreateExerciseError, CreateExerciseErrors, CreateExerciseRequest, CreateExerciseResponse, CreateExerciseResponses, CreateFeedbackData, CreateFeedbackError, CreateFeedbackErrors, CreateFeedbackRequest, CreateFeedbackResponses, DeleteExerciseData, DeleteExerciseError, DeleteExerciseErrors, DeleteExerciseResponse, DeleteExerciseResponses, ErrorResponse, ExercisePublic, FeedbackType, ForgotPasswordData, ForgotPasswordError, ForgotPasswordErrors, ForgotPasswordRequest, ForgotPasswordResponse, ForgotPasswordResponses, GetAccessRequestsData, GetAccessRequestsError, GetAccessRequestsErrors, GetAccessRequestsResponse, GetAccessRequestsResponses, GetCurrentUserData, GetCurrentUserError, GetCurrentUserErrors, GetCurrentUserResponse, GetCurrentUserResponses, GetDbHealthData, GetDbHealthResponse, GetDbHealthResponses, GetExerciseData, GetExerciseError, GetExerciseErrors, GetExerciseResponse, GetExerciseResponses, GetExercisesData, GetExercisesError, GetExercisesErrors, GetExercisesResponse, GetExercisesResponses, GetHealthData, GetHealthResponse, GetHealthResponses, GetMuscleGroupsData, GetMuscleGroupsError, GetMuscleGroupsErrors, GetMuscleGroupsResponse, GetMuscleGroupsResponses, GetUsersData, GetUsersError, GetUsersErrors, GetUsersResponse, GetUsersResponses, HttpValidationError, LoginData, LoginError, LoginErrors, LoginRequest, LoginResponse, LoginResponses, LogoutData, LogoutResponse, LogoutResponses, MuscleGroupPublic, RefreshTokenData, RefreshTokenError, RefreshTokenErrors, RefreshTokenResponse, RefreshTokenResponses, RegisterData, RegisterError, RegisterErrors, RegisterRequest, RegisterResponse, RegisterResponses, RequestAccessData, RequestAccessError, RequestAccessErrors, RequestAccessRequest, RequestAccessResponse, RequestAccessResponses, ResetPasswordData, ResetPasswordError, ResetPasswordErrors, ResetPasswordRequest, ResetPasswordResponse, ResetPasswordResponses, ReviewerPublic, UpdateAccessRequestStatusData, UpdateAccessRequestStatusError, UpdateAccessRequestStatusErrors, UpdateAccessRequestStatusRequest, UpdateAccessRequestStatusResponse, UpdateAccessRequestStatusResponses, UpdateExerciseData, UpdateExerciseError, UpdateExerciseErrors, UpdateExerciseRequest, UpdateExerciseResponse, UpdateExerciseResponses, UserPublic, ValidationError } from './types.gen';
export { AdminService, AuthService, ExercisesService, FeedbackService, HealthService, MuscleGroupsService, type Options, UserService, WorkoutsService } from './sdk.gen';
export type { AccessRequestPublic, AccessRequestStatus, ClientOptions, CreateExerciseData, CreateExerciseError, CreateExerciseErrors, CreateExerciseRequest, CreateExerciseResponse, CreateExerciseResponses, CreateFeedbackData, CreateFeedbackError, CreateFeedbackErrors, CreateFeedbackRequest, CreateFeedbackResponses, CreateWorkoutData, CreateWorkoutError, CreateWorkoutErrors, CreateWorkoutRequest, CreateWorkoutResponse, CreateWorkoutResponses, DeleteExerciseData, DeleteExerciseError, DeleteExerciseErrors, DeleteExerciseResponse, DeleteExerciseResponses, DeleteWorkoutData, DeleteWorkoutError, DeleteWorkoutErrors, DeleteWorkoutResponse, DeleteWorkoutResponses, ErrorResponse, ExerciseBase, ExercisePublic, FeedbackType, ForgotPasswordData, ForgotPasswordError, ForgotPasswordErrors, ForgotPasswordRequest, ForgotPasswordResponse, ForgotPasswordResponses, GetAccessRequestsData, GetAccessRequestsError, GetAccessRequestsErrors, GetAccessRequestsResponse, GetAccessRequestsResponses, GetCurrentUserData, GetCurrentUserError, GetCurrentUserErrors, GetCurrentUserResponse, GetCurrentUserResponses, GetDbHealthData, GetDbHealthResponse, GetDbHealthResponses, GetExerciseData, GetExerciseError, GetExerciseErrors, GetExerciseResponse, GetExerciseResponses, GetExercisesData, GetExercisesError, GetExercisesErrors, GetExercisesResponse, GetExercisesResponses, GetHealthData, GetHealthResponse, GetHealthResponses, GetMuscleGroupsData, GetMuscleGroupsError, GetMuscleGroupsErrors, GetMuscleGroupsResponse, GetMuscleGroupsResponses, GetUsersData, GetUsersError, GetUsersErrors, GetUsersResponse, GetUsersResponses, GetWorkoutData, GetWorkoutError, GetWorkoutErrors, GetWorkoutResponse, GetWorkoutResponses, GetWorkoutsData, GetWorkoutsError, GetWorkoutsErrors, GetWorkoutsResponse, GetWorkoutsResponses, HttpValidationError, LoginData, LoginError, LoginErrors, LoginRequest, LoginResponse, LoginResponses, LogoutData, LogoutResponse, LogoutResponses, MuscleGroupPublic, RefreshTokenData, RefreshTokenError, RefreshTokenErrors, RefreshTokenResponse, RefreshTokenResponses, RegisterData, RegisterError, RegisterErrors, RegisterRequest, RegisterResponse, RegisterResponses, RequestAccessData, RequestAccessError, RequestAccessErrors, RequestAccessRequest, RequestAccessResponse, RequestAccessResponses, ResetPasswordData, ResetPasswordError, ResetPasswordErrors, ResetPasswordRequest, ResetPasswordResponse, ResetPasswordResponses, ReviewerPublic, SetPublic, UpdateAccessRequestStatusData, UpdateAccessRequestStatusError, UpdateAccessRequestStatusErrors, UpdateAccessRequestStatusRequest, UpdateAccessRequestStatusResponse, UpdateAccessRequestStatusResponses, UpdateExerciseData, UpdateExerciseError, UpdateExerciseErrors, UpdateExerciseRequest, UpdateExerciseResponse, UpdateExerciseResponses, UpdateWorkoutData, UpdateWorkoutError, UpdateWorkoutErrors, UpdateWorkoutRequest, UpdateWorkoutResponse, UpdateWorkoutResponses, UserPublic, ValidationError, WorkoutBase, WorkoutExercisePublic, WorkoutPublic } from './types.gen';
Loading