From b9fcb159058967214248f1201ccdaa24fac13cdd Mon Sep 17 00:00:00 2001 From: tantranvn Date: Sat, 28 Mar 2026 21:55:14 +0700 Subject: [PATCH 01/15] Add comprehensive instructions for database migrations, frontend development, and testing - Created detailed instructions for database migrations using Alembic in the FastAPI backend. - Added guidelines for structuring and developing React/TypeScript components in the frontend. - Established best practices and patterns for writing backend tests with Pytest and frontend tests with Playwright. - Included examples for common tasks, error handling, and testing strategies across both backend and frontend. --- .DS_Store | Bin 0 -> 8196 bytes .env | 18 +- .github/copilot-instructions.md | 403 +++++++++++++ .github/instructions/README.md | 95 +++ .../backend-python.instructions.md | 298 ++++++++++ .../database-migrations.instructions.md | 447 ++++++++++++++ .../frontend-react.instructions.md | 421 ++++++++++++++ .github/instructions/testing.instructions.md | 545 ++++++++++++++++++ frontend/src/routeTree.gen.ts | 6 +- 9 files changed, 2221 insertions(+), 12 deletions(-) create mode 100644 .DS_Store create mode 100644 .github/copilot-instructions.md create mode 100644 .github/instructions/README.md create mode 100644 .github/instructions/backend-python.instructions.md create mode 100644 .github/instructions/database-migrations.instructions.md create mode 100644 .github/instructions/frontend-react.instructions.md create mode 100644 .github/instructions/testing.instructions.md diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..ec08acf111c5d22cefeddc56b91a5d2839b970ec GIT binary patch literal 8196 zcmeHMTWl0n7(U;$&{;ar0a___2R1B3z!rM3mTR)TDcC}>+tO_*EW0}+9hlCPo!Ko| zYBnamC}@1rc#A%HA@ZQ2h8Il~MIW`AV2lqKjh6>a^ab_7f9A}VULf(o7{xisod5i{ zbLRi&`_Jr|WsIRMr#3KF%@`Bua;aQS-F2F)cdvO(@Fk^0LH;Z~ZjYr6D^2``dDej- z$Uu;RAOk@Lf(!&1_%~#L-q~&wZ}8sd+Mo|I5Mb(%lo znKQ&V4f-GhK?de$z%8Frwuv2KI&)^8-_v7;Zaek$pF=1qU9hlBEECJcgYgk-Jnp2N zoZXhn9&oun%QDi%aVouE*T$1_WxHiMDc#hvo`bGxWHLOUo2oV5X5~!P_I%@9A%?_I zQm&bpsB36$Y^sgi*)mxhnP_NgXsC^}G&WC8hQzAq=Iy=7W5%dyo#sKn-w4>^IYFJ7 z8IfV<+xZuXl6WzqFNzU`7L`kjm4U?IkTjf>m*wf!d&V5yGVT3Z)+U;iD{KmJcg8aJ zWi?knkg<&1sA(tVrJ0nG$r&j}Yc-6xe!oVt3Okdv3}c^V>+Upz2}e8OxVm|&#E@-e zopx`sq%7xbtyiPD^t{$VGujEAmUJB}=LCn)xg{%BuU%i)*s>$mxqEN#RE4~B*>b5$ z8lqg9j((r6Wd~DsMl)62Jkmd|tIo(kUbpqMq4k-mQ7yC-wV|ns>XkLOh82YyTyrRz zCUR;0cusdt%284D)`%!;q+ykAZjYt9tEi%Up5~#|*C}h11Foh$t5L7|4jYu)r9PWt z5akp%+$7aYea8p{A=&1VCaGEJH&SWM2+1m6XN$B=8OZbehvY6U+$mKliHtQmN+H@I zE^AfVl|$~b-6`AY8PiPnmM)QwLc42?q71saR#o+GsHS>lh0>+$5#>+BA$ug1)q2JW zom0O2UQzU(HObxCI#u1Vrj`A!Qr6A9PQa(f%JDBoi;pH6+sKE}cg=z~;)#yN>U|TD zDXfuoux@skW!VHf!=7Yk*&FO4`+$AMzGC0AU)WUu3sHteC`Tn~uokswMhmuJE85V5 zedt9T!x({%qp)!TCozFjcnA;U5uCxJcmmJjIXsUS@CshT>o|`$@eVHGBYcccaRp!F z8+?nOa23DdccD~RAe0HBuv`cWim*zk7q$x9gzds!p+`svDdDJK31j^Jg<`24^yBe~ zaFPbon+KJe|M5>acl}Ze-@R-1JyQ37rqnJk@rtXm>XsFebsIO|v1MoLGzd9ri$I+T ze-HDsD#gRRhlx3Oo${(sTD7`ZcGn7#7iK>di*`bEYgR7vt)pv2Di(Qx&#vDRiqC%8QeRfrhSmrU|a7QOib4eEeE|gzp zSJ-##XZ8o7daIu-%4T*z2J?j(!|M5{Hq7icy%baGY>{3X^yM1w2S7KTSA) z43Fa}JdI}v>o4LZyo|GWm5}}x-o^#Ihxc(2ANrX8k`Vs`ew>5iJ+o1aUuU8?AJ3YG zWga0}5%sgpZz8UQ$5QQ{|2uE|{r@J4I0z-kK#+mC89-@AyrYezH`?*svv!28!*sdL xwJUJyxllLjLjdv9e;Cp@LZ+@y%%=rTNkZ)({}Axke`*Ks|KR=aVf1F~{se0-YJ~s* literal 0 HcmV?d00001 diff --git a/.env b/.env index 1d44286e25..ea21519f3e 100644 --- a/.env +++ b/.env @@ -13,20 +13,20 @@ FRONTEND_HOST=http://localhost:5173 # Environment: local, staging, production ENVIRONMENT=local -PROJECT_NAME="Full Stack FastAPI Project" -STACK_NAME=full-stack-fastapi-project +PROJECT_NAME="VNRunner" +STACK_NAME=vn-runner # Backend BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com" -SECRET_KEY=changethis -FIRST_SUPERUSER=admin@example.com -FIRST_SUPERUSER_PASSWORD=changethis +SECRET_KEY=vnrunner-secret-key +FIRST_SUPERUSER=admin@vnrunner.com +FIRST_SUPERUSER_PASSWORD=password # Emails SMTP_HOST= SMTP_USER= SMTP_PASSWORD= -EMAILS_FROM_EMAIL=info@example.com +EMAILS_FROM_EMAIL=info@vnrunner.com SMTP_TLS=True SMTP_SSL=False SMTP_PORT=587 @@ -34,9 +34,9 @@ SMTP_PORT=587 # Postgres POSTGRES_SERVER=localhost POSTGRES_PORT=5432 -POSTGRES_DB=app -POSTGRES_USER=postgres -POSTGRES_PASSWORD=changethis +POSTGRES_DB=vnrunner +POSTGRES_USER=tantran +POSTGRES_PASSWORD=password SENTRY_DSN= diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000..77bfa64bd1 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,403 @@ +# FastAPI Full-Stack Project Instructions + +This workspace uses the [FastAPI Full-Stack Template](https://github.com/fastapi/full-stack-fastapi-template), a production-ready full-stack application with modern Python backend and React frontend. + +## Technology Stack + +### Backend (Python/FastAPI) +- **FastAPI**: Modern, high-performance Python web framework +- **SQLModel**: SQL database ORM with Pydantic integration +- **PostgreSQL**: Primary database +- **Pydantic**: Data validation and settings management +- **Alembic**: Database migrations +- **JWT**: Token-based authentication +- **Pytest**: Testing framework + +### Frontend (TypeScript/React) +- **React**: UI framework +- **TypeScript**: Type-safe JavaScript +- **Vite**: Build tool and dev server +- **TanStack Query**: Data fetching and caching +- **TanStack Router**: Type-safe routing +- **Tailwind CSS**: Utility-first CSS framework +- **shadcn/ui**: Component library +- **Playwright**: End-to-end testing + +## Project Structure + +### Backend (`/backend`) +- `app/models.py` - SQLModel database models and Pydantic schemas +- `app/crud.py` - CRUD operations (Create, Read, Update, Delete) +- `app/api/routes/` - API endpoint definitions +- `app/api/deps.py` - FastAPI dependencies (auth, database sessions) +- `app/core/config.py` - Settings via Pydantic BaseSettings +- `app/core/security.py` - Password hashing and JWT token handling +- `app/core/db.py` - Database engine and session management +- `tests/` - Pytest test suite + +### Frontend (`/frontend`) +- `src/routes/` - TanStack Router route definitions +- `src/components/` - React components (organized by feature) +- `src/client/` - Auto-generated API client from OpenAPI spec +- `src/hooks/` - Custom React hooks +- `tests/` - Playwright end-to-end tests + +## Key Patterns and Conventions + +### Backend Patterns + +#### 1. Model Architecture (SQLModel) +Models follow a specific pattern with separate classes for different purposes: +- `*Base` - Shared properties between multiple models (e.g., `UserBase`) +- `*Create` - Properties received via API on creation (includes password) +- `*Update` - Properties for updates (all fields optional) +- `*Public` - Properties returned via API (excludes sensitive data like hashed_password) +- `*` (table=True) - Actual database table model +- `*sPublic` - List response with data array and count + +Example: +```python +class UserBase(SQLModel): + email: EmailStr = Field(unique=True, index=True) + is_active: bool = True + full_name: str | None = None + +class UserCreate(UserBase): + password: str = Field(min_length=8) + +class UserUpdate(UserBase): + email: EmailStr | None = None + password: str | None = None + +class User(UserBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + hashed_password: str + items: list["Item"] = Relationship(back_populates="owner") + +class UserPublic(UserBase): + id: uuid.UUID + +class UsersPublic(SQLModel): + data: list[UserPublic] + count: int +``` + +#### 2. CRUD Operations +- Defined in `app/crud.py` +- Accept `session: Session` as parameter +- Use SQLModel's `model_validate()` for creating objects +- Use `sqlmodel_update()` for updating objects +- Always commit and refresh after database operations + +```python +def create_user(*, session: Session, user_create: UserCreate) -> User: + db_obj = User.model_validate( + user_create, + update={"hashed_password": get_password_hash(user_create.password)} + ) + session.add(db_obj) + session.commit() + session.refresh(db_obj) + return db_obj +``` + +#### 3. API Routes +- Organized in `app/api/routes/` by resource (users, items, login) +- Use FastAPI's `APIRouter` with prefix and tags +- Leverage dependency injection for common patterns: + - `SessionDep` - Database session + - `CurrentUser` - Authenticated user + - `get_current_active_superuser` - Admin-only endpoints +- Follow RESTful conventions (GET, POST, PUT/PATCH, DELETE) +- Return appropriate response models + +```python +@router.get("/", response_model=ItemsPublic) +def read_items( + session: SessionDep, + current_user: CurrentUser, + skip: int = 0, + limit: int = 100 +) -> Any: + """Retrieve items.""" + # Superusers see all items, regular users see only their own + if current_user.is_superuser: + statement = select(Item).offset(skip).limit(limit) + else: + statement = select(Item).where(Item.owner_id == current_user.id) + items = session.exec(statement).all() + return ItemsPublic(data=items, count=len(items)) +``` + +#### 4. Dependencies (Dependency Injection) +- Defined in `app/api/deps.py` +- Use type annotations with `Annotated` for reusable dependencies +- Common dependencies: + - `SessionDep` - Database session from connection pool + - `TokenDep` - JWT token from OAuth2 scheme + - `CurrentUser` - Authenticated user from token + - `get_current_active_superuser()` - Admin user verification + +```python +SessionDep = Annotated[Session, Depends(get_db)] +CurrentUser = Annotated[User, Depends(get_current_user)] +``` + +#### 5. Authentication & Security +- JWT token-based authentication +- Passwords hashed with Argon2 +- Token issued via `/api/v1/login/access-token` endpoint +- Protected routes use `CurrentUser` dependency +- Admin-only routes use `Depends(get_current_active_superuser)` +- Timing attack prevention in authentication (dummy hash comparison) + +#### 6. Configuration +- Uses Pydantic `BaseSettings` for environment-based configuration +- Settings loaded from `../.env` file (one level above backend/) +- Database URL computed from individual components +- CORS origins validated and parsed +- Environment-specific behavior (local/staging/production) + +#### 7. Database Queries +- Use SQLModel's `select()` for queries +- Use `session.exec()` to execute statements +- Add `.where()` clauses for filtering +- Use `func.count()` for counting records +- Order with `.order_by()`, paginate with `.offset()` and `.limit()` + +```python +statement = ( + select(Item) + .where(Item.owner_id == user_id) + .order_by(col(Item.created_at).desc()) + .offset(skip) + .limit(limit) +) +items = session.exec(statement).all() +``` + +### Frontend Patterns + +#### 1. API Client +- Auto-generated from OpenAPI spec using `@hey-api/openapi-ts` +- Located in `src/client/` +- Regenerate with: `bash scripts/generate-client.sh` +- Import from `@/client` for type-safe API calls + +#### 2. React Components +- Organized by feature in `src/components/` (Admin, Items, UserSettings, etc.) +- Use shadcn/ui components from `src/components/ui/` +- Functional components with TypeScript +- Use custom hooks from `src/hooks/` + +#### 3. Routing +- TanStack Router for type-safe routing +- Route files in `src/routes/` +- Auto-generated route tree in `routeTree.gen.ts` +- Use layouts (`_layout.tsx`) for shared UI structure + +#### 4. State Management +- TanStack Query for server state (data fetching, caching, mutations) +- React hooks (useState, useContext) for local state +- Custom hooks for reusable logic (useAuth, useCustomToast) + +#### 5. Styling +- Tailwind CSS utility classes +- Theme support via `theme-provider.tsx` +- Dark mode compatible components +- shadcn/ui for pre-built accessible components + +## Development Workflow + +### Local Development + +#### Backend Development +```bash +cd backend +uv sync # Install dependencies +source .venv/bin/activate # Activate virtual environment +fastapi dev app/main.py # Run development server +``` + +#### Frontend Development +```bash +cd frontend +bun install # Install dependencies +bun run dev # Run development server (http://localhost:5173) +``` + +#### Docker Compose (Recommended) +```bash +docker compose watch # Start full stack with hot reload +``` +Access points: +- Frontend: http://localhost:5173 +- Backend API: http://localhost:8000 +- API Docs: http://localhost:8000/docs +- Adminer (DB): http://localhost:8080 +- Mailcatcher: http://localhost:1080 + +### Testing + +#### Backend Tests +```bash +cd backend +bash scripts/test.sh # Run all tests +pytest tests/api/ # Run specific test directory +pytest -k "test_name" # Run specific test +``` + +#### Frontend Tests +```bash +cd frontend +bun run test:e2e # Run Playwright tests +``` + +### Database Migrations + +#### Create Migration +```bash +cd backend +alembic revision --autogenerate -m "Add new field" +``` + +#### Apply Migrations +```bash +alembic upgrade head +``` + +### Code Quality + +#### Backend Linting/Formatting +```bash +cd backend +bash scripts/format.sh # Format code with ruff +bash scripts/lint.sh # Lint code +``` + +#### Frontend Linting/Formatting +```bash +cd frontend +bun run format # Format with Biome +bun run lint # Lint with Biome +``` + +## Common Tasks + +### Adding a New Backend Feature + +1. **Define Models** in `backend/app/models.py`: + - Create Base, Create, Update, Public, and table models + - Add relationships if needed + - Include proper Field validators + +2. **Create CRUD Functions** in `backend/app/crud.py`: + - Add create, read, update, delete functions + - Use session parameter and proper error handling + +3. **Create API Routes** in `backend/app/api/routes/`: + - Create new router file if needed + - Define endpoints with proper HTTP methods + - Use dependencies (SessionDep, CurrentUser) + - Add response models and docstrings + +4. **Register Router** in `backend/app/api/main.py`: + ```python + api_router.include_router(your_router.router) + ``` + +5. **Create Migration**: + ```bash + cd backend + alembic revision --autogenerate -m "Add your_feature" + alembic upgrade head + ``` + +6. **Write Tests** in `backend/tests/`: + - Create test file in appropriate subdirectory + - Use fixtures from conftest.py + - Test CRUD operations and API endpoints + +7. **Regenerate Frontend Client**: + ```bash + bash scripts/generate-client.sh + ``` + +### Adding a New Frontend Feature + +1. **Create Component** in `frontend/src/components/`: + - Use TypeScript for type safety + - Import shadcn/ui components as needed + - Use TanStack Query for data fetching + +2. **Create Route** (if needed) in `frontend/src/routes/`: + - Define route file with proper exports + - Use loader for data fetching + - Handle authentication if required + +3. **Use API Client** from `src/client/`: + - Import generated client functions + - Wrap in TanStack Query hooks + +4. **Write Tests** in `frontend/tests/`: + - Create Playwright test spec + - Use auth setup from `auth.setup.ts` + - Test user interactions and assertions + +## File Organization Best Practices + +### Backend +- Keep models thin - business logic goes in CRUD functions or services +- One router per resource/feature +- Use deps.py for reusable dependencies +- Put utilities in `app/utils.py` +- Configuration only in `core/config.py` + +### Frontend +- Components organized by feature (Admin/, Items/, UserSettings/) +- Shared components in Common/ or ui/ +- One route file per page +- Custom hooks in hooks/ directory +- Utilities in lib/ or utils.ts + +## Security Considerations + +- Never expose `hashed_password` in API responses (use *Public models) +- Always validate user permissions before operations +- Use `CurrentUser` dependency for authenticated endpoints +- Check ownership or superuser status for resource access +- Passwords must be min 8 characters +- JWT tokens expire after 8 days (configurable) +- CORS configured via settings + +## Environment Variables + +Key variables (in `.env` file at project root): +- `PROJECT_NAME` - Application name +- `SECRET_KEY` - JWT signing key +- `POSTGRES_*` - Database connection details +- `FIRST_SUPERUSER*` - Initial admin user +- `SMTP_*` - Email configuration +- `FRONTEND_HOST` - Frontend URL for CORS + +## Common Gotchas + +1. **Database Session**: Always use `SessionDep` dependency, never create sessions manually +2. **Model Validation**: Use `model_validate()` for creating and `sqlmodel_update()` for updating +3. **Commit & Refresh**: Always commit changes and refresh objects to get updated data +4. **Response Models**: Specify `response_model` on all endpoints to ensure proper serialization +5. **UUID Types**: Use `uuid.UUID` type, not strings, for ID fields +6. **Frontend Client**: Regenerate after backend API changes +7. **Pre-commit Hooks**: Install with `pre-commit install` to auto-format on commit + +## Additional Resources + +- Project README: `/README.md` +- Development Guide: `/development.md` +- Contributing: `/CONTRIBUTING.md` +- Backend README: `/backend/README.md` +- Frontend README: `/frontend/README.md` +- FastAPI Docs: https://fastapi.tiangolo.com +- SQLModel Docs: https://sqlmodel.tiangolo.com +- TanStack Query: https://tanstack.com/query +- TanStack Router: https://tanstack.com/router diff --git a/.github/instructions/README.md b/.github/instructions/README.md new file mode 100644 index 0000000000..704664343e --- /dev/null +++ b/.github/instructions/README.md @@ -0,0 +1,95 @@ +# Agent Instructions + +This directory contains specialized instruction files that guide AI agents (like GitHub Copilot) when working with specific parts of the codebase. + +## Instruction Files + +### 1. Backend Python Instructions +**File:** `backend-python.instructions.md` +**Applies to:** `backend/**/*.py` (excluding tests) + +Provides guidance for: +- SQLModel model patterns (Base, Create, Update, Table, Public models) +- CRUD operations with proper session handling +- FastAPI route patterns and dependency injection +- Database queries with SQLModel +- Security best practices +- Import organization + +### 2. Frontend React Instructions +**File:** `frontend-react.instructions.md` +**Applies to:** `frontend/src/**/*.{ts,tsx}` (excluding tests and generated code) + +Provides guidance for: +- React component structure and organization +- TanStack Query for data fetching (queries and mutations) +- TanStack Router for routing +- TypeScript patterns and type safety +- shadcn/ui component usage +- Tailwind CSS styling conventions +- Custom hooks usage +- Form handling and validation + +### 3. Testing Instructions +**File:** `testing.instructions.md` +**Applies to:** `backend/tests/**/*.py` and `frontend/tests/**/*.{ts,spec.ts}` + +Provides guidance for: +- Pytest patterns for backend testing +- Playwright patterns for frontend E2E testing +- Test fixtures and utilities +- API endpoint testing +- CRUD operation testing +- Permission and authentication testing +- Form and UI interaction testing + +### 4. Database Migration Instructions +**File:** `database-migrations.instructions.md` +**Applies to:** `backend/alembic/**/*.py` and `backend/alembic.ini` + +Provides guidance for: +- Creating and applying Alembic migrations +- Common migration patterns (add column, rename, indexes, foreign keys) +- Data migration patterns +- Migration testing and troubleshooting +- Best practices for reversible migrations +- Environment-specific considerations + +## Workspace-Level Instructions + +**File:** `../.github/copilot-instructions.md` + +This file contains general project instructions that apply to the entire workspace, including: +- Project overview and technology stack +- Directory structure +- Development workflows +- Common tasks and patterns +- Security considerations +- Environment setup + +## How These Work + +These instruction files are automatically detected by GitHub Copilot and used to provide context-aware assistance when you're working on files that match the `applyTo` patterns defined in each file's frontmatter. + +For example: +- When editing a file in `backend/app/api/routes/`, the **Backend Python Instructions** will be loaded +- When editing a React component in `frontend/src/components/`, the **Frontend React Instructions** will be loaded +- When editing a test file, the **Testing Instructions** will be loaded + +## Updating Instructions + +If you need to update these instructions: +1. Edit the relevant `.instructions.md` file +2. Keep the YAML frontmatter intact (between the `---` markers) +3. Ensure the `applyTo` patterns are correct +4. Test with Copilot to verify the instructions are being applied + +## Template Source + +This project is based on the [FastAPI Full-Stack Template](https://github.com/fastapi/full-stack-fastapi-template). + +For more information about the template and its conventions, see: +- Project README: `/README.md` +- Development Guide: `/development.md` +- Backend README: `/backend/README.md` +- Frontend README: `/frontend/README.md` diff --git a/.github/instructions/backend-python.instructions.md b/.github/instructions/backend-python.instructions.md new file mode 100644 index 0000000000..a2289a28e8 --- /dev/null +++ b/.github/instructions/backend-python.instructions.md @@ -0,0 +1,298 @@ +--- +description: "Instructions for working with FastAPI backend Python files including models, CRUD operations, API routes, and dependencies." +applyTo: "backend/**/*.py" +--- + +# Backend Python Development Instructions + +## FastAPI Backend Patterns + +### When working with Models (`backend/app/models.py`) + +Always follow the SQLModel pattern with separate classes: + +1. **Base Model** - Shared properties: + ```python + class ResourceBase(SQLModel): + name: str = Field(min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=500) + ``` + +2. **Create Model** - Properties for API creation: + ```python + class ResourceCreate(ResourceBase): + # Add any create-specific fields + pass + ``` + +3. **Update Model** - All fields optional: + ```python + class ResourceUpdate(SQLModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = Field(default=None, max_length=500) + ``` + +4. **Table Model** - Database table (use `table=True`): + ```python + class Resource(ResourceBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + created_at: datetime | None = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), + ) + owner_id: uuid.UUID = Field(foreign_key="user.id") + owner: User = Relationship(back_populates="resources") + ``` + +5. **Public Model** - API response (excludes sensitive data): + ```python + class ResourcePublic(ResourceBase): + id: uuid.UUID + created_at: datetime | None = None + ``` + +6. **List Public Model** - Paginated response: + ```python + class ResourcesPublic(SQLModel): + data: list[ResourcePublic] + count: int + ``` + +**Critical Rules:** +- NEVER expose `hashed_password` or sensitive fields in Public models +- Always use `uuid.UUID` type for IDs, not strings +- Use `Field()` for validation (min_length, max_length, unique, index) +- Use `Relationship()` for foreign key relationships +- Include `created_at` timestamp for all table models + +### When working with CRUD (`backend/app/crud.py`) + +CRUD functions should: + +1. **Accept session as parameter**: + ```python + def create_resource(*, session: Session, resource_in: ResourceCreate, owner_id: uuid.UUID) -> Resource: + ``` + +2. **Use `model_validate()` for creation**: + ```python + db_obj = Resource.model_validate( + resource_in, + update={"owner_id": owner_id} + ) + session.add(db_obj) + session.commit() + session.refresh(db_obj) + return db_obj + ``` + +3. **Use `sqlmodel_update()` for updates**: + ```python + def update_resource(*, session: Session, db_obj: Resource, resource_in: ResourceUpdate) -> Resource: + resource_data = resource_in.model_dump(exclude_unset=True) + db_obj.sqlmodel_update(resource_data) + session.add(db_obj) + session.commit() + session.refresh(db_obj) + return db_obj + ``` + +4. **Always commit and refresh** after database operations + +**Critical Rules:** +- Use keyword-only arguments (start with `*`) +- Always get objects before updating them +- Use `exclude_unset=True` to only update provided fields +- Include error handling for database constraints + +### When working with API Routes (`backend/app/api/routes/*.py`) + +1. **Router setup**: + ```python + from fastapi import APIRouter, HTTPException + from app.api.deps import CurrentUser, SessionDep + + router = APIRouter(prefix="/resources", tags=["resources"]) + ``` + +2. **List endpoint**: + ```python + @router.get("/", response_model=ResourcesPublic) + def read_resources( + session: SessionDep, + current_user: CurrentUser, + skip: int = 0, + limit: int = 100 + ) -> Any: + """Retrieve resources.""" + # Superusers see all, users see only their own + if current_user.is_superuser: + statement = select(Resource).offset(skip).limit(limit) + else: + statement = select(Resource).where(Resource.owner_id == current_user.id) + resources = session.exec(statement).all() + count = len(resources) + return ResourcesPublic(data=resources, count=count) + ``` + +3. **Get by ID**: + ```python + @router.get("/{id}", response_model=ResourcePublic) + def read_resource(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any: + """Get resource by ID.""" + resource = session.get(Resource, id) + if not resource: + raise HTTPException(status_code=404, detail="Resource not found") + if not current_user.is_superuser and resource.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + return resource + ``` + +4. **Create**: + ```python + @router.post("/", response_model=ResourcePublic) + def create_resource( + *, session: SessionDep, current_user: CurrentUser, resource_in: ResourceCreate + ) -> Any: + """Create new resource.""" + resource = crud.create_resource( + session=session, resource_in=resource_in, owner_id=current_user.id + ) + return resource + ``` + +5. **Update**: + ```python + @router.put("/{id}", response_model=ResourcePublic) + def update_resource( + *, session: SessionDep, current_user: CurrentUser, id: uuid.UUID, resource_in: ResourceUpdate + ) -> Any: + """Update resource.""" + resource = session.get(Resource, id) + if not resource: + raise HTTPException(status_code=404, detail="Resource not found") + if not current_user.is_superuser and resource.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + resource = crud.update_resource(session=session, db_obj=resource, resource_in=resource_in) + return resource + ``` + +6. **Delete**: + ```python + @router.delete("/{id}") + def delete_resource(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Message: + """Delete resource.""" + resource = session.get(Resource, id) + if not resource: + raise HTTPException(status_code=404, detail="Resource not found") + if not current_user.is_superuser and resource.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + session.delete(resource) + session.commit() + return Message(message="Resource deleted successfully") + ``` + +**Critical Rules:** +- Always specify `response_model` on endpoints +- Use `SessionDep` and `CurrentUser` dependencies +- Check permissions (superuser or ownership) +- Return 404 if not found, 403 if no permission +- Use descriptive docstrings +- For admin-only: `dependencies=[Depends(get_current_active_superuser)]` + +### When working with Dependencies (`backend/app/api/deps.py`) + +Use `Annotated` for type-safe dependencies: + +```python +SessionDep = Annotated[Session, Depends(get_db)] +TokenDep = Annotated[str, Depends(reusable_oauth2)] +CurrentUser = Annotated[User, Depends(get_current_user)] +``` + +Custom dependencies should: +- Accept other dependencies as parameters +- Raise HTTPException for errors (404, 403, 401) +- Return the dependency value + +### Database Queries + +Use SQLModel's query patterns: + +```python +# Simple select +statement = select(Resource).where(Resource.owner_id == user_id) +resources = session.exec(statement).all() + +# With ordering and pagination +statement = ( + select(Resource) + .where(Resource.owner_id == user_id) + .order_by(col(Resource.created_at).desc()) + .offset(skip) + .limit(limit) +) + +# Count +count_statement = select(func.count()).select_from(Resource) +count = session.exec(count_statement).one() + +# Get by ID +resource = session.get(Resource, id) +``` + +### Configuration (`backend/app/core/config.py`) + +Settings use Pydantic BaseSettings: +- Read from `../.env` file (one level above backend/) +- Use `computed_field` for derived properties +- Use `model_validator` for complex validation +- No hardcoded secrets + +### Security Best Practices + +1. **Never expose sensitive data** in Public models +2. **Always check permissions** before operations +3. **Use timing-attack prevention** in authentication +4. **Hash passwords** with Argon2 (via `get_password_hash()`) +5. **Validate tokens** with proper error handling +6. **Check ownership** or superuser status for resources + +### Import Organization + +Order imports as: +1. Standard library +2. Third-party (FastAPI, SQLModel, etc.) +3. Local application imports + +```python +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException +from sqlmodel import Session, select + +from app.api.deps import CurrentUser, SessionDep +from app.models import Resource, ResourceCreate, ResourcePublic +``` + +### Error Handling + +Use HTTPException with appropriate status codes: +- 400: Bad Request (validation errors) +- 401: Unauthorized (missing/invalid token) +- 403: Forbidden (insufficient permissions) +- 404: Not Found +- 409: Conflict (duplicate resource) +- 500: Internal Server Error (unexpected errors) + +### When Adding New Models/Routes + +1. Define all model classes in `models.py` +2. Add CRUD functions in `crud.py` +3. Create router file in `api/routes/` +4. Register router in `api/main.py` +5. Create migration: `alembic revision --autogenerate -m "description"` +6. Apply migration: `alembic upgrade head` +7. Write tests in `tests/api/routes/` or `tests/crud/` +8. Regenerate frontend client: `bash scripts/generate-client.sh` diff --git a/.github/instructions/database-migrations.instructions.md b/.github/instructions/database-migrations.instructions.md new file mode 100644 index 0000000000..826a5692a1 --- /dev/null +++ b/.github/instructions/database-migrations.instructions.md @@ -0,0 +1,447 @@ +--- +description: "Instructions for working with database migrations using Alembic in the FastAPI template." +applyTo: "backend/alembic/**/*" +--- + +# Database Migration Instructions + +## Alembic Migrations + +This project uses [Alembic](https://alembic.sqlalchemy.org/) for database migrations with SQLModel. + +### Migration File Location + +``` +backend/ +├── alembic.ini # Alembic configuration +└── app/ + └── alembic/ + ├── env.py # Migration environment setup + ├── script.py.mako # Migration template + └── versions/ # Migration files + ├── xxxx_initial.py + └── yyyy_add_field.py +``` + +## Common Migration Tasks + +### Creating a New Migration + +After modifying models in `backend/app/models.py`: + +```bash +cd backend + +# Activate virtual environment +source .venv/bin/activate + +# Generate migration automatically (recommended) +alembic revision --autogenerate -m "Add user profile fields" + +# Or create empty migration (for data migrations) +alembic revision -m "Migrate user data" +``` + +**What --autogenerate does:** +- Compares database schema with SQLModel models +- Generates migration with `upgrade()` and `downgrade()` functions +- Creates new file in `app/alembic/versions/` + +### Migration File Structure + +```python +"""Add user profile fields + +Revision ID: abc123def456 +Revises: xyz789 +Create Date: 2024-03-28 10:30:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel + +# Revision identifiers +revision = 'abc123def456' +down_revision = 'xyz789' # Previous migration +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Apply changes to database.""" + # Add column + op.add_column('user', sa.Column('bio', sa.String(length=500), nullable=True)) + + # Create index + op.create_index('ix_user_bio', 'user', ['bio']) + + # Add foreign key + op.add_column('item', sa.Column('category_id', sa.UUID(), nullable=True)) + op.create_foreign_key( + 'fk_item_category', + 'item', + 'category', + ['category_id'], + ['id'] + ) + + +def downgrade() -> None: + """Revert changes.""" + op.drop_constraint('fk_item_category', 'item', type_='foreignkey') + op.drop_column('item', 'category_id') + op.drop_index('ix_user_bio', 'user') + op.drop_column('user', 'bio') +``` + +### Applying Migrations + +```bash +# Apply all pending migrations +alembic upgrade head + +# Apply specific number of migrations forward +alembic upgrade +2 + +# Apply to specific revision +alembic upgrade abc123def456 + +# Show current revision +alembic current + +# Show migration history +alembic history + +# Show pending migrations +alembic heads +``` + +### Reverting Migrations + +```bash +# Revert last migration +alembic downgrade -1 + +# Revert all migrations +alembic downgrade base + +# Revert to specific revision +alembic downgrade xyz789 + +# Revert specific number of migrations +alembic downgrade -2 +``` + +## Migration Patterns + +### Adding a Column + +```python +def upgrade() -> None: + op.add_column( + 'table_name', + sa.Column('new_field', sa.String(length=255), nullable=True) + ) + +def downgrade() -> None: + op.drop_column('table_name', 'new_field') +``` + +### Adding a Required Column (with default) + +```python +def upgrade() -> None: + # Step 1: Add column as nullable + op.add_column( + 'table_name', + sa.Column('required_field', sa.String(length=255), nullable=True) + ) + + # Step 2: Set default values for existing rows + op.execute("UPDATE table_name SET required_field = 'default' WHERE required_field IS NULL") + + # Step 3: Make column non-nullable + op.alter_column('table_name', 'required_field', nullable=False) + +def downgrade() -> None: + op.drop_column('table_name', 'required_field') +``` + +### Renaming a Column + +```python +def upgrade() -> None: + op.alter_column('table_name', 'old_name', new_column_name='new_name') + +def downgrade() -> None: + op.alter_column('table_name', 'new_name', new_column_name='old_name') +``` + +### Adding an Index + +```python +def upgrade() -> None: + op.create_index('ix_table_field', 'table_name', ['field_name']) + +def downgrade() -> None: + op.drop_index('ix_table_field', 'table_name') +``` + +### Adding a Foreign Key + +```python +def upgrade() -> None: + op.add_column( + 'child_table', + sa.Column('parent_id', sa.UUID(), nullable=True) + ) + op.create_foreign_key( + 'fk_child_parent', + 'child_table', + 'parent_table', + ['parent_id'], + ['id'], + ondelete='CASCADE' # Optional: cascade delete + ) + +def downgrade() -> None: + op.drop_constraint('fk_child_parent', 'child_table', type_='foreignkey') + op.drop_column('child_table', 'parent_id') +``` + +### Creating a New Table + +```python +def upgrade() -> None: + op.create_table( + 'new_table', + sa.Column('id', sa.UUID(), primary_key=True), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('owner_id', sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(['owner_id'], ['user.id']), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_new_table_name', 'new_table', ['name']) + +def downgrade() -> None: + op.drop_index('ix_new_table_name', 'new_table') + op.drop_table('new_table') +``` + +### Data Migration + +```python +from alembic import op +from sqlalchemy import text + +def upgrade() -> None: + # Migrate data + op.execute( + text(""" + UPDATE user + SET full_name = CONCAT(first_name, ' ', last_name) + WHERE full_name IS NULL + """) + ) + +def downgrade() -> None: + # Revert data migration + op.execute( + text(""" + UPDATE user + SET full_name = NULL + """) + ) +``` + +### Adding Enum Type + +```python +import enum +from sqlalchemy.dialects import postgresql + +class StatusEnum(enum.Enum): + ACTIVE = "active" + INACTIVE = "inactive" + PENDING = "pending" + +def upgrade() -> None: + # Create enum type (PostgreSQL) + status_enum = postgresql.ENUM('active', 'inactive', 'pending', name='status_enum') + status_enum.create(op.get_bind()) + + # Add column with enum + op.add_column( + 'table_name', + sa.Column('status', sa.Enum(StatusEnum), nullable=False, server_default='pending') + ) + +def downgrade() -> None: + op.drop_column('table_name', 'status') + + # Drop enum type + status_enum = postgresql.ENUM(name='status_enum') + status_enum.drop(op.get_bind()) +``` + +## Best Practices + +### 1. Always Review Auto-generated Migrations + +```bash +# After generating migration +alembic revision --autogenerate -m "description" + +# Check the generated file in app/alembic/versions/ +# Review upgrade() and downgrade() functions +# Make manual adjustments if needed +``` + +**Common issues with --autogenerate:** +- Doesn't detect column renames (sees as drop + add) +- Doesn't detect table renames +- May miss some constraint changes +- Doesn't handle data migrations + +### 2. Make Migrations Reversible + +Always implement `downgrade()` function: + +```python +def upgrade() -> None: + op.add_column('user', sa.Column('bio', sa.String(500))) + +def downgrade() -> None: + op.drop_column('user', 'bio') # Must be reversible! +``` + +### 3. Test Migrations + +```bash +# Test upgrade +alembic upgrade head + +# Test downgrade +alembic downgrade -1 + +# Test upgrade again +alembic upgrade head +``` + +### 4. Use Descriptive Names + +Good: `alembic revision --autogenerate -m "Add user bio and avatar fields"` +Bad: `alembic revision --autogenerate -m "Update user"` + +### 5. Keep Migrations Small + +- One logical change per migration +- Don't mix schema changes with data migrations +- Split large migrations into smaller ones + +### 6. Handle Existing Data + +When adding non-nullable columns: +1. Add as nullable first +2. Populate existing rows +3. Make non-nullable + +### 7. Use Batch Operations for SQLite + +For SQLite compatibility (local dev): + +```python +def upgrade() -> None: + with op.batch_alter_table('table_name') as batch_op: + batch_op.add_column(sa.Column('new_field', sa.String(255))) + batch_op.create_index('ix_table_new_field', ['new_field']) +``` + +## Troubleshooting + +### Migration Conflicts + +If you get "Multiple heads" error: + +```bash +# See all heads +alembic heads + +# Merge migrations +alembic merge heads -m "Merge migrations" +``` + +### Reset Database (Development Only) + +```bash +# WARNING: Destroys all data! +alembic downgrade base +alembic upgrade head +``` + +Or use Docker: + +```bash +docker compose down -v # Removes volumes +docker compose up -d +``` + +### Check Migration SQL + +See SQL without applying: + +```bash +# Show SQL for next migration +alembic upgrade head --sql + +# Show SQL for specific revision +alembic upgrade abc123:def456 --sql +``` + +### Fix Failed Migration + +If migration fails midway: + +```bash +# Check current revision +alembic current + +# Manually fix database or migration file + +# Stamp to specific revision (mark as complete without running) +alembic stamp abc123def456 + +# Continue from there +alembic upgrade head +``` + +## Environment-Specific Migrations + +Migrations run in: +- **Development**: Local PostgreSQL (Docker) +- **Testing**: Test database (automatic in tests) +- **Production**: Production database (CI/CD) + +**Never run migrations manually in production!** Use deployment scripts. + +## Workflow Checklist + +When adding new models/fields: + +- [ ] Update models in `backend/app/models.py` +- [ ] Generate migration: `alembic revision --autogenerate -m "description"` +- [ ] Review generated migration file +- [ ] Test upgrade: `alembic upgrade head` +- [ ] Test downgrade: `alembic downgrade -1` +- [ ] Test upgrade again: `alembic upgrade head` +- [ ] Commit migration file to git +- [ ] Update tests if needed +- [ ] Regenerate frontend client: `bash scripts/generate-client.sh` + +## Additional Resources + +- Alembic Documentation: https://alembic.sqlalchemy.org/ +- SQLModel Documentation: https://sqlmodel.tiangolo.com/ +- Project Guide: `/backend/README.md` diff --git a/.github/instructions/frontend-react.instructions.md b/.github/instructions/frontend-react.instructions.md new file mode 100644 index 0000000000..1adb4ba3aa --- /dev/null +++ b/.github/instructions/frontend-react.instructions.md @@ -0,0 +1,421 @@ +--- +description: "Instructions for working with React/TypeScript frontend files including components, routes, hooks, and API client usage." +applyTo: "frontend/src/**/*.{ts,tsx}" +--- + +# Frontend React/TypeScript Development Instructions + +## React + TypeScript + Vite Patterns + +### Component Structure + +Organize components by feature in `frontend/src/components/`: + +``` +components/ +├── Admin/ # Admin-specific components +├── Items/ # Item management components +├── UserSettings/ # User settings components +├── Common/ # Shared components +├── Sidebar/ # Navigation components +└── ui/ # shadcn/ui components (don't modify) +``` + +### Component Pattern + +Use functional components with TypeScript: + +```tsx +import { useState } from 'react' +import { useQuery, useMutation } from '@tanstack/react-query' +import { Button } from '@/components/ui/button' +import { ResourcesService, type ResourcePublic, type ResourceCreate } from '@/client' +import { useCustomToast } from '@/hooks/useCustomToast' + +interface ResourceListProps { + userId?: string + showAll?: boolean +} + +export function ResourceList({ userId, showAll = false }: ResourceListProps) { + const [isOpen, setIsOpen] = useState(false) + const showToast = useCustomToast() + + // Data fetching with TanStack Query + const { data, isLoading, error } = useQuery({ + queryKey: ['resources', userId], + queryFn: () => ResourcesService.readResources({ skip: 0, limit: 100 }), + }) + + // Mutation for create/update + const createMutation = useMutation({ + mutationFn: (resourceData: ResourceCreate) => + ResourcesService.createResource({ requestBody: resourceData }), + onSuccess: () => { + showToast('Success', 'Resource created successfully', 'success') + queryClient.invalidateQueries({ queryKey: ['resources'] }) + }, + onError: (err) => { + showToast('Error', 'Failed to create resource', 'error') + }, + }) + + if (isLoading) return
Loading...
+ if (error) return
Error loading resources
+ + return ( +
+ {data?.data.map((resource) => ( + + ))} +
+ ) +} +``` + +### Key Patterns + +#### 1. API Client Usage + +Import from auto-generated client (regenerate after backend changes): + +```tsx +import { + ResourcesService, + UsersService, + type ResourcePublic, + type ResourceCreate, + type UserPublic +} from '@/client' +``` + +**Never modify files in `src/client/` - they are auto-generated!** + +#### 2. TanStack Query for Data Fetching + +**Query (GET requests):** +```tsx +const { data, isLoading, error, refetch } = useQuery({ + queryKey: ['resources', filters], // Cache key + queryFn: () => ResourcesService.readResources({ skip: 0, limit: 100 }), + enabled: !!userId, // Only run if condition is true +}) +``` + +**Mutation (POST/PUT/DELETE):** +```tsx +const mutation = useMutation({ + mutationFn: (data: ResourceCreate) => + ResourcesService.createResource({ requestBody: data }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['resources'] }) + showToast('Success', 'Created successfully', 'success') + }, + onError: (error) => { + showToast('Error', error.message, 'error') + }, +}) + +// Use in handler +const handleCreate = async (data: ResourceCreate) => { + mutation.mutate(data) +} +``` + +#### 3. Custom Hooks + +Use hooks from `src/hooks/`: + +```tsx +import { useAuth } from '@/hooks/useAuth' +import { useCustomToast } from '@/hooks/useCustomToast' +import { useMobile } from '@/hooks/useMobile' + +function MyComponent() { + const { user, logout } = useAuth() + const showToast = useCustomToast() + const isMobile = useMobile() + + // Your component logic +} +``` + +#### 4. Form Handling + +Forms should use controlled components: + +```tsx +import { useState } from 'react' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' + +function ResourceForm() { + const [formData, setFormData] = useState({ + title: '', + description: '', + }) + + const handleChange = (e: React.ChangeEvent) => { + setFormData({ ...formData, [e.target.name]: e.target.value }) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + // Submit logic + } + + return ( +
+ + +
+ ) +} +``` + +#### 5. shadcn/ui Components + +Import pre-built components from `@/components/ui/`: + +```tsx +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +``` + +**Do not modify files in `components/ui/` - they are managed by shadcn!** + +#### 6. Styling with Tailwind + +Use Tailwind utility classes: + +```tsx +
+

Title

+ +
+``` + +Common patterns: +- Layout: `flex`, `grid`, `space-y-4`, `gap-4` +- Spacing: `p-4`, `m-2`, `px-6`, `py-3` +- Colors: `bg-primary`, `text-muted-foreground`, `border-border` +- Dark mode: automatic via theme (don't add dark: variants manually) + +### Routing with TanStack Router + +Route files in `frontend/src/routes/`: + +```tsx +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { ResourceList } from '@/components/Resources/ResourceList' + +export const Route = createFileRoute('/resources')({ + component: ResourcesPage, + // Optional: Load data before rendering + loader: async () => { + const resources = await ResourcesService.readResources() + return { resources } + }, +}) + +function ResourcesPage() { + const { resources } = Route.useLoaderData() + const navigate = useNavigate() + + return ( +
+

Resources

+ +
+ ) +} +``` + +**Route structure maps to URLs:** +- `routes/index.tsx` → `/` +- `routes/resources/index.tsx` → `/resources` +- `routes/resources/$id.tsx` → `/resources/:id` +- `routes/_layout.tsx` → Layout wrapper for child routes + +### Authentication + +Use the `useAuth` hook: + +```tsx +import { useAuth } from '@/hooks/useAuth' + +function ProtectedComponent() { + const { user, isLoading, logout } = useAuth() + + if (isLoading) return
Loading...
+ if (!user) return
Please login
+ + return ( +
+

Welcome, {user.full_name}

+ {user.is_superuser && } +
+ ) +} +``` + +### TypeScript Best Practices + +1. **Use generated types** from `@/client`: + ```tsx + import type { ResourcePublic, UserPublic } from '@/client' + ``` + +2. **Define component props**: + ```tsx + interface ComponentProps { + title: string + isActive?: boolean + onSubmit: (data: FormData) => Promise + } + ``` + +3. **Type event handlers**: + ```tsx + const handleClick = (e: React.MouseEvent) => {} + const handleChange = (e: React.ChangeEvent) => {} + const handleSubmit = (e: React.FormEvent) => {} + ``` + +4. **Type state properly**: + ```tsx + const [data, setData] = useState(null) + const [items, setItems] = useState([]) + ``` + +### State Management + +1. **Server State**: Use TanStack Query (for API data) +2. **Component State**: Use `useState` (for UI state) +3. **Shared State**: Use React Context or prop drilling +4. **Form State**: Controlled components with `useState` + +### Error Handling + +Always handle errors from API calls: + +```tsx +const { data, error, isLoading } = useQuery({ + queryKey: ['resource', id], + queryFn: () => ResourcesService.readResource({ id }), +}) + +if (isLoading) return +if (error) { + return +} +if (!data) return null +``` + +### Performance Optimization + +1. **Memoize expensive computations**: + ```tsx + const filteredItems = useMemo( + () => items.filter(item => item.active), + [items] + ) + ``` + +2. **Memoize callbacks**: + ```tsx + const handleClick = useCallback(() => { + doSomething(id) + }, [id]) + ``` + +3. **Use query invalidation** instead of refetching: + ```tsx + queryClient.invalidateQueries({ queryKey: ['resources'] }) + ``` + +### Accessibility + +- Use semantic HTML elements +- Add ARIA labels when needed +- shadcn/ui components have accessibility built-in +- Test keyboard navigation + +### Testing + +Write Playwright tests in `frontend/tests/`: + +```typescript +import { test, expect } from '@playwright/test' + +test('should create resource', async ({ page }) => { + await page.goto('/resources') + await page.click('button:has-text("New Resource")') + await page.fill('input[name="title"]', 'Test Resource') + await page.click('button:has-text("Create")') + await expect(page.locator('text=Test Resource')).toBeVisible() +}) +``` + +### Common Patterns + +#### Loading States +```tsx +{isLoading && } +{data && } +``` + +#### Empty States +```tsx +{data?.data.length === 0 && ( + Create First Resource} + /> +)} +``` + +#### Modals/Dialogs +```tsx + + + + + + + Dialog Title + + {/* Dialog content */} + + +``` + +### When Adding New Features + +1. Check if API client needs regeneration: `bash scripts/generate-client.sh` +2. Create component in appropriate feature folder +3. Use TanStack Query for data fetching +4. Import types from `@/client` +5. Use shadcn/ui components for UI +6. Style with Tailwind classes +7. Handle loading and error states +8. Write Playwright tests +9. Test dark mode compatibility + +### Don't Do This + +❌ Modify files in `src/client/` (auto-generated) +❌ Modify files in `src/components/ui/` (managed by shadcn) +❌ Use inline styles (use Tailwind classes) +❌ Fetch data with fetch/axios (use generated client) +❌ Store server state in useState (use TanStack Query) +❌ Forget error handling +❌ Skip TypeScript types diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md new file mode 100644 index 0000000000..b33f6e4988 --- /dev/null +++ b/.github/instructions/testing.instructions.md @@ -0,0 +1,545 @@ +--- +description: "Instructions for writing and maintaining tests for both backend (Pytest) and frontend (Playwright) in the FastAPI full-stack template." +applyTo: "**/tests/**/*" +--- + +# Testing Instructions + +## Backend Tests (Pytest) + +### Test Organization + +``` +backend/tests/ +├── conftest.py # Pytest fixtures +├── api/ # API endpoint tests +│ └── routes/ +│ ├── test_items.py +│ ├── test_users.py +│ └── test_login.py +├── crud/ # CRUD operation tests +│ └── test_user.py +└── utils/ # Test utilities + ├── user.py + ├── item.py + └── utils.py +``` + +### Fixtures (conftest.py) + +Use shared fixtures from `conftest.py`: + +```python +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session + +def test_example(client: TestClient, db: Session): + # client: TestClient - FastAPI test client + # db: Session - Database session + pass + +def test_with_auth(client: TestClient, superuser_token_headers: dict[str, str]): + # superuser_token_headers: Auth headers for admin + pass + +def test_normal_user(client: TestClient, normal_user_token_headers: dict[str, str]): + # normal_user_token_headers: Auth headers for regular user + pass +``` + +### API Endpoint Tests + +Test API endpoints in `backend/tests/api/routes/`: + +```python +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app import crud +from app.models import ResourceCreate, User +from tests.utils.utils import random_string + + +def test_create_resource( + client: TestClient, + superuser_token_headers: dict[str, str], + db: Session +) -> None: + """Test creating a new resource.""" + data = { + "title": random_string(), + "description": "Test description", + } + response = client.post( + "/api/v1/resources/", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == 200 + content = response.json() + assert content["title"] == data["title"] + assert "id" in content + + +def test_read_resource( + client: TestClient, + superuser_token_headers: dict[str, str], + db: Session +) -> None: + """Test reading a resource by ID.""" + # Create test resource + resource = crud.create_resource( + session=db, + resource_in=ResourceCreate(title="Test", description="Test"), + owner_id=superuser.id + ) + + # Read it back + response = client.get( + f"/api/v1/resources/{resource.id}", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + content = response.json() + assert content["id"] == str(resource.id) + + +def test_update_resource( + client: TestClient, + superuser_token_headers: dict[str, str], + db: Session +) -> None: + """Test updating a resource.""" + # Create resource + resource = crud.create_resource(...) + + # Update it + update_data = {"title": "Updated Title"} + response = client.put( + f"/api/v1/resources/{resource.id}", + headers=superuser_token_headers, + json=update_data, + ) + assert response.status_code == 200 + content = response.json() + assert content["title"] == update_data["title"] + + +def test_delete_resource( + client: TestClient, + superuser_token_headers: dict[str, str], + db: Session +) -> None: + """Test deleting a resource.""" + resource = crud.create_resource(...) + + response = client.delete( + f"/api/v1/resources/{resource.id}", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + + # Verify deletion + response = client.get( + f"/api/v1/resources/{resource.id}", + headers=superuser_token_headers, + ) + assert response.status_code == 404 + + +def test_permission_check( + client: TestClient, + normal_user_token_headers: dict[str, str], + db: Session +) -> None: + """Test permission checks - normal user cannot access other's resources.""" + # Create resource owned by someone else + other_resource = crud.create_resource(...) + + # Try to access as normal user + response = client.get( + f"/api/v1/resources/{other_resource.id}", + headers=normal_user_token_headers, + ) + assert response.status_code == 403 +``` + +### CRUD Tests + +Test CRUD functions in `backend/tests/crud/`: + +```python +from sqlmodel import Session + +from app import crud +from app.models import ResourceCreate, ResourceUpdate +from tests.utils.utils import random_string + + +def test_create_resource(db: Session) -> None: + """Test creating a resource via CRUD.""" + resource_in = ResourceCreate( + title=random_string(), + description="Test description", + ) + resource = crud.create_resource( + session=db, + resource_in=resource_in, + owner_id=owner.id, + ) + assert resource.title == resource_in.title + assert resource.owner_id == owner.id + + +def test_update_resource(db: Session) -> None: + """Test updating a resource via CRUD.""" + # Create + resource = crud.create_resource(...) + + # Update + resource_in = ResourceUpdate(title="Updated") + updated = crud.update_resource( + session=db, + db_obj=resource, + resource_in=resource_in, + ) + assert updated.title == "Updated" + assert updated.id == resource.id + + +def test_get_resource_by_id(db: Session) -> None: + """Test getting a resource by ID.""" + resource = crud.create_resource(...) + + fetched = crud.get_resource(session=db, resource_id=resource.id) + assert fetched + assert fetched.id == resource.id +``` + +### Test Utilities + +Use utilities from `tests/utils/`: + +```python +from tests.utils.utils import random_string, random_email + +# Generate random test data +title = random_string() # Random lowercase string +email = random_email() # Random email address +``` + +### Best Practices for Backend Tests + +1. **Use descriptive test names**: `test_create_resource_as_admin()` +2. **Test both success and failure cases**: 404, 403, 400 errors +3. **Use fixtures for setup**: Don't repeat database setup +4. **Clean up in fixtures**: Use conftest.py for cleanup +5. **Test permissions**: Superuser vs regular user access +6. **Use random data**: Avoid hard-coded test data +7. **Assert specific values**: Check IDs, fields, status codes +8. **Test edge cases**: Empty strings, None values, large inputs + +### Running Backend Tests + +```bash +# Run all tests +cd backend +bash scripts/test.sh + +# Run specific test file +pytest tests/api/routes/test_items.py + +# Run specific test +pytest tests/api/routes/test_items.py::test_create_item + +# Run with coverage +pytest --cov=app tests/ + +# Run tests matching pattern +pytest -k "test_create" +``` + +## Frontend Tests (Playwright) + +### Test Organization + +``` +frontend/tests/ +├── auth.setup.ts # Authentication setup +├── config.ts # Test configuration +├── login.spec.ts # Login tests +├── items.spec.ts # Items feature tests +├── user-settings.spec.ts +└── utils/ + └── ... # Test utilities +``` + +### Authentication Setup + +Tests use authenticated sessions from `auth.setup.ts`: + +```typescript +import { test as setup } from '@playwright/test' + +// Setup runs before tests to create authenticated sessions +``` + +### Writing Frontend Tests + +```typescript +import { test, expect } from '@playwright/test' + +test.describe('Resources', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/resources') + }) + + test('should display resources list', async ({ page }) => { + await expect(page.locator('h1')).toContainText('Resources') + await expect(page.locator('[data-testid="resource-card"]')).toBeVisible() + }) + + test('should create new resource', async ({ page }) => { + // Click create button + await page.click('button:has-text("New Resource")') + + // Fill form + await page.fill('input[name="title"]', 'Test Resource') + await page.fill('textarea[name="description"]', 'Test description') + + // Submit + await page.click('button[type="submit"]') + + // Verify success + await expect(page.locator('text=Test Resource')).toBeVisible() + await expect(page.locator('text=created successfully')).toBeVisible() + }) + + test('should edit resource', async ({ page }) => { + // Click first resource + await page.click('[data-testid="resource-card"]:first-child') + + // Click edit button + await page.click('button:has-text("Edit")') + + // Update title + await page.fill('input[name="title"]', 'Updated Title') + await page.click('button:has-text("Save")') + + // Verify update + await expect(page.locator('text=Updated Title')).toBeVisible() + }) + + test('should delete resource', async ({ page }) => { + // Find and click delete button + await page.click('[data-testid="resource-card"]:first-child button:has-text("Delete")') + + // Confirm deletion + await page.click('button:has-text("Confirm")') + + // Verify deletion + await expect(page.locator('text=deleted successfully')).toBeVisible() + }) + + test('should handle validation errors', async ({ page }) => { + await page.click('button:has-text("New Resource")') + + // Try to submit without filling required fields + await page.click('button[type="submit"]') + + // Check for error messages + await expect(page.locator('text=Title is required')).toBeVisible() + }) + + test('should paginate results', async ({ page }) => { + // Assuming pagination exists + await page.click('button:has-text("Next")') + + // Verify URL changed + await expect(page).toHaveURL(/page=2/) + }) +}) +``` + +### Testing Patterns + +#### Form Testing +```typescript +test('should submit form correctly', async ({ page }) => { + await page.goto('/form-page') + + // Fill all fields + await page.fill('input[name="name"]', 'Test Name') + await page.selectOption('select[name="category"]', 'option1') + await page.check('input[type="checkbox"]') + + // Submit and verify + await page.click('button[type="submit"]') + await expect(page.locator('.success-message')).toBeVisible() +}) +``` + +#### Navigation Testing +```typescript +test('should navigate between pages', async ({ page }) => { + await page.goto('/') + + // Click navigation link + await page.click('a[href="/resources"]') + + // Verify navigation + await expect(page).toHaveURL('/resources') + await expect(page.locator('h1')).toContainText('Resources') +}) +``` + +#### Authentication Testing +```typescript +test('should protect authenticated routes', async ({ page }) => { + // Try to access protected page without auth + await page.goto('/admin') + + // Should redirect to login + await expect(page).toHaveURL('/login') +}) + +test('should allow access with authentication', async ({ page }) => { + // Login is handled by auth.setup.ts + await page.goto('/admin') + + // Should access the page + await expect(page.locator('h1')).toContainText('Admin') +}) +``` + +#### API Testing (via UI) +```typescript +test('should handle API errors gracefully', async ({ page }) => { + // Simulate error by providing invalid data + await page.goto('/resources') + await page.click('button:has-text("New Resource")') + + // Invalid data + await page.fill('input[name="title"]', 'x'.repeat(1000)) + await page.click('button[type="submit"]') + + // Should show error message + await expect(page.locator('.error-message')).toBeVisible() +}) +``` + +### Best Practices for Frontend Tests + +1. **Use data-testid attributes**: Add to key elements for stable selectors +2. **Wait for elements**: Use `await expect().toBeVisible()` +3. **Test user journeys**: Not just individual actions +4. **Test error states**: Invalid inputs, API failures +5. **Test accessibility**: Keyboard navigation, screen readers +6. **Use page objects**: For complex pages (optional) +7. **Keep tests independent**: Each test should work in isolation +8. **Mock external APIs**: When needed (via Playwright) + +### Running Frontend Tests + +```bash +cd frontend + +# Run all tests +bun run test:e2e + +# Run in headed mode (see browser) +bun run test:e2e --headed + +# Run specific test file +bun run test:e2e tests/items.spec.ts + +# Run in debug mode +bun run test:e2e --debug + +# Run with UI mode +bun run test:e2e --ui +``` + +## Test Data Best Practices + +### Backend +- Use `random_string()` and `random_email()` from test utils +- Create data in test, clean up in fixtures +- Don't rely on seed data +- Test with realistic data sizes + +### Frontend +- Use consistent test data +- Clean up after tests +- Don't hard-code IDs or timestamps +- Test with various screen sizes + +## Continuous Integration + +Tests run automatically on: +- Pull requests +- Pushes to main branch +- Via GitHub Actions (see `.github/workflows/`) + +Make sure all tests pass before committing! + +## Writing New Tests + +### For New Backend Feature +1. Add CRUD tests in `tests/crud/` +2. Add API tests in `tests/api/routes/` +3. Test permissions (superuser vs regular user) +4. Test error cases (404, 403, 400) +5. Run: `bash scripts/test.sh` + +### For New Frontend Feature +1. Add test file in `tests/` +2. Test main user flows +3. Test form validation +4. Test error states +5. Run: `bun run test:e2e` + +## Common Test Patterns + +### Backend: Test with Different Users +```python +def test_as_superuser(client: TestClient, superuser_token_headers: dict): + # Test admin functionality + pass + +def test_as_normal_user(client: TestClient, normal_user_token_headers: dict): + # Test regular user functionality + pass + +def test_without_auth(client: TestClient): + # Test unauthenticated access (should fail) + response = client.get("/api/v1/protected") + assert response.status_code == 401 +``` + +### Frontend: Test Loading States +```typescript +test('should show loading state', async ({ page }) => { + await page.goto('/resources') + + // Should show loading initially + await expect(page.locator('text=Loading')).toBeVisible() + + // Should show content after loading + await expect(page.locator('[data-testid="resource-card"]')).toBeVisible() +}) +``` + +### Frontend: Test Dark Mode +```typescript +test('should work in dark mode', async ({ page }) => { + await page.goto('/resources') + + // Toggle dark mode + await page.click('button[aria-label="Toggle theme"]') + + // Verify dark mode applied + await expect(page.locator('html')).toHaveClass(/dark/) +}) +``` diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 8849130b4c..08d665fef8 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -65,6 +65,7 @@ const LayoutAdminRoute = LayoutAdminRouteImport.update({ } as any) export interface FileRoutesByFullPath { + '/': typeof LayoutIndexRoute '/login': typeof LoginRoute '/recover-password': typeof RecoverPasswordRoute '/reset-password': typeof ResetPasswordRoute @@ -72,7 +73,6 @@ export interface FileRoutesByFullPath { '/admin': typeof LayoutAdminRoute '/items': typeof LayoutItemsRoute '/settings': typeof LayoutSettingsRoute - '/': typeof LayoutIndexRoute } export interface FileRoutesByTo { '/login': typeof LoginRoute @@ -99,6 +99,7 @@ export interface FileRoutesById { export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: + | '/' | '/login' | '/recover-password' | '/reset-password' @@ -106,7 +107,6 @@ export interface FileRouteTypes { | '/admin' | '/items' | '/settings' - | '/' fileRoutesByTo: FileRoutesByTo to: | '/login' @@ -171,7 +171,7 @@ declare module '@tanstack/react-router' { '/_layout': { id: '/_layout' path: '' - fullPath: '' + fullPath: '/' preLoaderRoute: typeof LayoutRouteImport parentRoute: typeof rootRouteImport } From 583829959117fc60e73de7eab877cec069d89818 Mon Sep 17 00:00:00 2001 From: tantranvn Date: Sat, 28 Mar 2026 23:12:42 +0700 Subject: [PATCH 02/15] feat: Add RBAC with roles and user-role relationship, including API routes for role management and frontend components for public layout --- .github/copilot-instructions.md | 92 +++- .gitignore | 1 + PUBLIC_ROUTES_IMPLEMENTATION.md | 203 +++++++++ RBAC.md | 416 ++++++++++++++++++ ...c748_add_rbac_with_roles_and_user_role_.py | 45 ++ backend/app/api/deps.py | 36 ++ backend/app/api/main.py | 3 +- backend/app/api/routes/roles.py | 182 ++++++++ backend/app/core/db.py | 25 +- backend/app/crud.py | 69 ++- backend/app/models.py | 65 ++- compose.yml | 32 ++ frontend/src/client/schemas.gen.ts | 125 ++++++ frontend/src/client/sdk.gen.ts | 159 ++++++- frontend/src/client/types.gen.ts | 69 +++ .../src/components/Public/PublicFooter.tsx | 100 +++++ .../src/components/Public/PublicHeader.tsx | 105 +++++ .../src/components/Sidebar/AppSidebar.tsx | 14 +- frontend/src/hooks/useAuth.ts | 2 +- frontend/src/index.css | 40 ++ frontend/src/routeTree.gen.ts | 252 ++++++++--- frontend/src/routes/_layout.admin.tsx | 28 ++ .../index.tsx => _layout.admin/dashboard.tsx} | 8 +- frontend/src/routes/_layout.admin/index.tsx | 9 + .../{_layout => _layout.admin}/items.tsx | 14 +- .../{_layout => _layout.admin}/settings.tsx | 6 +- .../admin.tsx => _layout.admin/users.tsx} | 20 +- frontend/src/routes/_public.tsx | 21 + frontend/src/routes/_public/about.tsx | 114 +++++ frontend/src/routes/_public/index.tsx | 125 ++++++ frontend/src/routes/_public/races.tsx | 124 ++++++ frontend/src/routes/login.tsx | 2 +- 32 files changed, 2393 insertions(+), 113 deletions(-) create mode 100644 PUBLIC_ROUTES_IMPLEMENTATION.md create mode 100644 RBAC.md create mode 100644 backend/app/alembic/versions/5280d245c748_add_rbac_with_roles_and_user_role_.py create mode 100644 backend/app/api/routes/roles.py create mode 100644 frontend/src/components/Public/PublicFooter.tsx create mode 100644 frontend/src/components/Public/PublicHeader.tsx create mode 100644 frontend/src/routes/_layout.admin.tsx rename frontend/src/routes/{_layout/index.tsx => _layout.admin/dashboard.tsx} (74%) create mode 100644 frontend/src/routes/_layout.admin/index.tsx rename frontend/src/routes/{_layout => _layout.admin}/items.tsx (80%) rename frontend/src/routes/{_layout => _layout.admin}/settings.tsx (89%) rename frontend/src/routes/{_layout/admin.tsx => _layout.admin/users.tsx} (76%) create mode 100644 frontend/src/routes/_public.tsx create mode 100644 frontend/src/routes/_public/about.tsx create mode 100644 frontend/src/routes/_public/index.tsx create mode 100644 frontend/src/routes/_public/races.tsx diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 77bfa64bd1..c92f70b375 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,6 +1,6 @@ # FastAPI Full-Stack Project Instructions -This workspace uses the [FastAPI Full-Stack Template](https://github.com/fastapi/full-stack-fastapi-template), a production-ready full-stack application with modern Python backend and React frontend. +This workspace uses the [FastAPI Full-Stack Template](https://github.com/fastapi/full-stack-fastapi-template), a production-ready full-stack application with modern Python backend and React frontend, **enhanced with RBAC (Role-Based Access Control) and a separate Next.js runner site**. ## Technology Stack @@ -11,9 +11,10 @@ This workspace uses the [FastAPI Full-Stack Template](https://github.com/fastapi - **Pydantic**: Data validation and settings management - **Alembic**: Database migrations - **JWT**: Token-based authentication +- **RBAC**: Role-based access control with 4 predefined roles - **Pytest**: Testing framework -### Frontend (TypeScript/React) +### Frontend - Admin Portal (TypeScript/React) - **React**: UI framework - **TypeScript**: Type-safe JavaScript - **Vite**: Build tool and dev server @@ -22,28 +23,103 @@ This workspace uses the [FastAPI Full-Stack Template](https://github.com/fastapi - **Tailwind CSS**: Utility-first CSS framework - **shadcn/ui**: Component library - **Playwright**: End-to-end testing +- **Access**: `dashboard.domain.com` (admin and organizers only) + +### Frontend - Runner Site (TypeScript/Next.js) +- **Next.js 15**: React framework with SSR/SSG for SEO +- **TypeScript**: Type-safe JavaScript +- **Tailwind CSS**: Utility-first CSS framework +- **App Router**: Next.js 15 app directory +- **Server Components**: For optimal performance +- **Access**: `domain.com` (public + authenticated runners) ## Project Structure ### Backend (`/backend`) -- `app/models.py` - SQLModel database models and Pydantic schemas -- `app/crud.py` - CRUD operations (Create, Read, Update, Delete) +- `app/models.py` - SQLModel database models and Pydantic schemas (includes Role and RBAC) +- `app/crud.py` - CRUD operations including role management - `app/api/routes/` - API endpoint definitions -- `app/api/deps.py` - FastAPI dependencies (auth, database sessions) + - `roles.py` - Role management endpoints (admin-only) + - `users.py` - User management with role assignment + - `items.py` - Example resource endpoints +- `app/api/deps.py` - FastAPI dependencies (auth, database sessions, RBAC) - `app/core/config.py` - Settings via Pydantic BaseSettings - `app/core/security.py` - Password hashing and JWT token handling -- `app/core/db.py` - Database engine and session management +- `app/core/db.py` - Database engine, session management, and role initialization - `tests/` - Pytest test suite -### Frontend (`/frontend`) +### Frontend - Admin Portal (`/frontend`) - `src/routes/` - TanStack Router route definitions - `src/components/` - React components (organized by feature) + - `Admin/` - Admin-specific components + - `Items/` - Resource management + - `UserSettings/` - User profile management - `src/client/` - Auto-generated API client from OpenAPI spec -- `src/hooks/` - Custom React hooks +- `src/hooks/` - Custom React hooks (useAuth, etc.) - `tests/` - Playwright end-to-end tests +- **Purpose**: Admin dashboard for system management + +### Frontend - Runner Site (`/runner-site`) +- `app/` - Next.js 15 app directory + - `page.tsx` - Home page (public) + - `login/` - Authentication pages + - `dashboard/` - Runner dashboard (authenticated) +- `lib/` - Utilities and configurations + - `api-client.ts` - API client with authentication + - `auth-context.tsx` - React context for authentication + - `config.ts` - Environment configuration +- **Purpose**: Public-facing site for runners with SEO optimization ## Key Patterns and Conventions +### RBAC (Role-Based Access Control) + +The system includes a comprehensive RBAC system with four predefined roles: + +1. **Admin** - Full system access, user and role management +2. **Runner** - Register for races, manage own profile +3. **Organizer** - Create and manage races +4. **Volunteer** - Assist with race operations + +#### Using RBAC in Endpoints + +```python +from app.api.deps import AdminUser, RunnerUser, require_role, require_any_role +from fastapi import Depends + +# Method 1: Predefined dependencies +@router.get("/admin-only") +def admin_endpoint(current_user: AdminUser) -> Any: + """Only admins can access.""" + return {"message": "Admin access"} + +# Method 2: Custom role requirements +@router.get("/staff", dependencies=[Depends(require_any_role(["organizer", "volunteer"]))]) +def staff_endpoint() -> Any: + """Staff members can access.""" + return {"message": "Staff access"} + +# Method 3: Manual checking +from app import crud + +@router.post("/races") +def create_race(current_user: CurrentUser, race_in: RaceCreate) -> Any: + if not crud.user_has_any_role(current_user, ["admin", "organizer"]): + raise HTTPException(status_code=403, detail="Insufficient permissions") + return race +``` + +**Note**: Users with `is_superuser=True` bypass all role checks. + +#### Role Management API + +- `GET /api/v1/roles/` - List all roles (admin-only) +- `POST /api/v1/roles/` - Create role (admin-only) +- `POST /api/v1/roles/{role_id}/assign/{user_id}` - Assign role to user +- `DELETE /api/v1/roles/{role_id}/remove/{user_id}` - Remove role from user + +For complete RBAC documentation, see [RBAC.md](../RBAC.md). + ### Backend Patterns #### 1. Model Architecture (SQLModel) diff --git a/.gitignore b/.gitignore index f903ab6066..5a5b06cbdf 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ node_modules/ /playwright-report/ /blob-report/ /playwright/.cache/ +frontend/.tanstack/ \ No newline at end of file diff --git a/PUBLIC_ROUTES_IMPLEMENTATION.md b/PUBLIC_ROUTES_IMPLEMENTATION.md new file mode 100644 index 0000000000..eb020d8513 --- /dev/null +++ b/PUBLIC_ROUTES_IMPLEMENTATION.md @@ -0,0 +1,203 @@ +# Public Routes Implementation Guide + +## Overview + +Successfully implemented Option 1: Unified React Application with public and protected routes. The existing React admin portal has been extended to include public pages, eliminating the need for a separate Next.js runner site. + +## What Was Implemented + +### 1. Public Layout System +- **Created**: `frontend/src/routes/_public.tsx` - Layout wrapper for all public pages +- **Components**: + - `PublicHeader.tsx` - Navigation header with login/signup buttons + - `PublicFooter.tsx` - Footer with links and branding + +### 2. Public Pages +All public pages are accessible without authentication: + +- **Home Page** (`/`) + - Hero section with CTAs + - Features showcase + - Call-to-action for registration + +- **Races Page** (`/races`) + - Browse upcoming races + - Placeholder data (ready for API integration) + - Filter section for future enhancements + +- **About Page** (`/about`) + - Platform information + - Mission statement + - Features for runners and organizers + +### 3. Route Structure + +``` +frontend/src/routes/ +├── __root.tsx # Root layout +├── _public.tsx # Public layout (no auth required) +│ ├── index.tsx # Home page (/) +│ ├── about.tsx # About page (/about) +│ └── races.tsx # Races listing (/races) +├── _layout.tsx # Protected layout (requires auth) +│ ├── dashboard.tsx # Dashboard (/dashboard) - NEW! +│ ├── admin.tsx # Admin panel (/admin) +│ ├── items.tsx # Items management (/items) +│ └── settings.tsx # User settings (/settings) +├── login.tsx # Login page (/login) +└── signup.tsx # Signup page (/signup) +``` + +### 4. Authentication Flow + +- **Public visitors**: Can browse `/`, `/races`, `/about` +- **Already logged in**: Header shows "Dashboard" button +- **After login**: Redirects to `/dashboard` instead of `/` +- **Protected routes**: Require authentication, redirect to `/login` if not logged in + +## SEO Support + +All public pages include meta tags via TanStack Router's `head` function: + +```typescript +export const Route = createFileRoute("/_public/")({ + component: HomePage, + head: () => ({ + meta: [ + { + title: "RaceHub - Find and Register for Running Races", + description: "Discover running races near you...", + }, + ], + }), +}) +``` + +## Next Steps + +### 1. Add Race Management API + +Create race endpoints in the backend: + +```bash +# backend/app/models.py - Add Race model +# backend/app/crud.py - Add race CRUD operations +# backend/app/api/routes/races.py - Add race endpoints +``` + +### 2. Connect Races Page to API + +Update `frontend/src/routes/_public/races.tsx` to fetch real data: + +```typescript +import { useQuery } from "@tanstack/react-query" +import { RacesService } from "@/client" + +const { data: races } = useQuery({ + queryKey: ["races"], + queryFn: RacesService.listRaces, +}) +``` + +### 3. Add Race Registration Flow + +- Create race detail page: `/races/$raceId` +- Add registration form for authenticated users +- Handle payment processing + +### 4. Enhance SEO (Optional) + +For critical SEO needs, add prerendering: + +```bash +cd frontend +bun add -D vite-plugin-ssr +``` + +Configure in `vite.config.ts` to prerender static pages. + +### 5. Clean Up Runner Site (Optional) + +The `runner-site/` directory is now obsolete and can be: +- Deleted completely, or +- Kept as backup/reference during transition + +## Testing the Implementation + +1. **Start the dev server**: + ```bash + cd frontend + bun run dev + ``` + +2. **Visit public pages** (no login required): + - http://localhost:5173/ - Home page + - http://localhost:5173/races - Races listing + - http://localhost:5173/about - About page + +3. **Test authentication flow**: + - Click "Login" in header + - Log in with test credentials + - Should redirect to `/dashboard` + - Header should show "Dashboard" button + +4. **Test protected routes**: + - Try accessing `/dashboard`, `/admin`, `/items` without login + - Should redirect to `/login` + +## Benefits Achieved + +✅ **Single Codebase**: No more maintaining two separate frontends +✅ **Shared Infrastructure**: Reuse API client, components, utilities +✅ **Type Safety**: Full TypeScript + TanStack Router type safety +✅ **Consistent UX**: Same UI patterns across all pages +✅ **Auto-generated API Client**: Changes to backend API automatically update frontend +✅ **Modern SEO**: Meta tags support with potential for prerendering +✅ **Faster Development**: One build process, one deployment + +## Updated Documentation + +- ✅ RBAC.md updated to reflect unified architecture +- ✅ Removed references to separate Next.js runner site +- ✅ Updated deployment instructions + +## Files Modified + +### Created: +- `frontend/src/components/Public/PublicHeader.tsx` +- `frontend/src/components/Public/PublicFooter.tsx` +- `frontend/src/routes/_public.tsx` +- `frontend/src/routes/_public/index.tsx` +- `frontend/src/routes/_public/about.tsx` +- `frontend/src/routes/_public/races.tsx` + +### Modified: +- `frontend/src/routes/_layout/index.tsx` → `dashboard.tsx` (moved) +- `frontend/src/hooks/useAuth.ts` (redirect to /dashboard) +- `frontend/src/routes/login.tsx` (redirect to /dashboard) +- `RBAC.md` (updated architecture documentation) + +### Auto-generated: +- `frontend/src/routeTree.gen.ts` (TanStack Router route tree) + +## Troubleshooting + +### "Route not found" errors +- Route tree regenerates automatically when dev server runs +- If issues persist, restart the dev server + +### Type errors on routes +- Make sure dev server has run at least once to generate route tree +- Check `frontend/src/routeTree.gen.ts` exists + +### Styling issues +- All components use Tailwind CSS and shadcn/ui +- Dark mode supported via theme provider + +## Questions? + +Refer to: +- Main README: `/README.md` +- RBAC Documentation: `/RBAC.md` +- Frontend README: `/frontend/README.md` +- TanStack Router Docs: https://tanstack.com/router diff --git a/RBAC.md b/RBAC.md new file mode 100644 index 0000000000..5a07427e0f --- /dev/null +++ b/RBAC.md @@ -0,0 +1,416 @@ +# RBAC (Role-Based Access Control) System + +## Overview + +The system includes a comprehensive RBAC system that allows fine-grained permission control for different user types. The application uses a single React frontend that serves both public pages and role-based authenticated dashboards. + +## Roles + +The system defines four default roles: + +### 1. Admin +- **Purpose**: System administrators with full access +- **Permissions**: Full CRUD access to all resources, user management, role management +- **Access**: Protected dashboard routes with admin-only features + +### 2. Runner +- **Purpose**: Regular users who participate in races +- **Permissions**: Register for races, view own profile and race history, update own information +- **Access**: Public pages + personal dashboard when authenticated + +### 3. Organizer +- **Purpose**: Race organizers who create and manage events +- **Permissions**: Create/edit/delete races, manage race registrations, view participant lists +- **Access**: Protected dashboard with race management features + +### 4. Volunteer +- **Purpose**: Volunteers who help with race operations +- **Permissions**: Check-in runners, view race information, assist with race day operations +- **Access**: Limited dashboard for race day operations + +## Architecture + +### Database Schema + +``` +User (existing table) +├── id: UUID (PK) +├── email: String +├── hashed_password: String +├── is_active: Boolean +├── is_superuser: Boolean +└── roles: Relationship → UserRoleLink + +Role (new table) +├── id: UUID (PK) +├── name: String (unique) +├── description: String +└── users: Relationship → UserRoleLink + +UserRoleLink (new junction table) +├── user_id: UUID (FK → User) +└── role_id: UUID (FK → Role) +``` + +### Backend Implementation + +#### Models (`backend/app/models.py`) + +- **RoleEnum**: Enum defining the four role types +- **Role**: SQLModel table for roles +- **UserRoleLink**: Many-to-many relationship table +- **User**: Extended with `roles` relationship + +#### CRUD Operations (`backend/app/crud.py`) + +```python +# Role management +create_role(session, role_create) +get_role_by_name(session, name) +get_or_create_role(session, role_name, description) + +# User-role management +assign_role_to_user(session, user, role) +remove_role_from_user(session, user, role) +user_has_role(user, role_name) +user_has_any_role(user, role_names) +``` + +#### Dependencies (`backend/app/api/deps.py`) + +##### Permission Checking Functions +```python +# Factory functions for custom role requirements +require_role(required_role) # Single role +require_any_role(required_roles) # Any of multiple roles +``` + +##### Predefined Dependencies +```python +AdminUser # Requires 'admin' role +RunnerUser # Requires 'runner' role +OrganizerUser # Requires 'organizer' role +VolunteerUser # Requires 'volunteer' role +AdminOrOrganizerUser # Requires 'admin' OR 'organizer' +``` + +#### API Routes (`backend/app/api/routes/roles.py`) + +All role management endpoints are admin-only: + +- `GET /api/v1/roles/` - List all roles +- `GET /api/v1/roles/{role_id}` - Get role by ID +- `POST /api/v1/roles/` - Create new role +- `PUT /api/v1/roles/{role_id}` - Update role +- `DELETE /api/v1/roles/{role_id}` - Delete role +- `POST /api/v1/roles/{role_id}/assign/{user_id}` - Assign role to user +- `DELETE /api/v1/roles/{role_id}/remove/{user_id}` - Remove role from user + +## Using RBAC in API Endpoints + +### Method 1: Using Predefined Dependencies + +```python +from app.api.deps import AdminUser, RunnerUser, OrganizerUser + +@router.get("/admin-only") +def admin_endpoint(current_user: AdminUser) -> Any: + """Only admins can access this.""" + return {"message": "Admin access"} + +@router.get("/runner-only") +def runner_endpoint(current_user: RunnerUser) -> Any: + """Only runners can access this.""" + return {"message": "Runner access"} +``` + +### Method 2: Using Custom Role Requirements + +```python +from fastapi import Depends +from app.api.deps import require_role, require_any_role + +@router.get("/organizers", dependencies=[Depends(require_role("organizer"))]) +def organizer_endpoint() -> Any: + """Only organizers can access this.""" + return {"message": "Organizer access"} + +@router.get("/staff", dependencies=[Depends(require_any_role(["organizer", "volunteer"]))]) +def staff_endpoint() -> Any: + """Organizers or volunteers can access this.""" + return {"message": "Staff access"} +``` + +### Method 3: Manual Permission Checking + +```python +from app import crud +from app.api.deps import CurrentUser + +@router.post("/races") +def create_race(current_user: CurrentUser, race_in: RaceCreate) -> Any: + """Create a race - requires organizer or admin role.""" + if not crud.user_has_any_role(current_user, ["admin", "organizer"]): + raise HTTPException(status_code=403, detail="Insufficient permissions") + + # Create race logic here + return race +``` + +## Superuser Override + +**Important**: Users with `is_superuser=True` bypass all role checks. They automatically have access to all endpoints regardless of role requirements. + +## Frontend Integration + +### Unified React Application (`frontend/`) + +The React frontend serves both public and authenticated users: +- **URL**: `domain.com` (production) or `http://localhost:5173` (local) +- **Technology**: React + Vite with TanStack Router & Query +- **Auto-generated API Client**: Type-safe client from OpenAPI spec + +### Route Structure + +#### Public Routes (`_public/`) +- **Access**: All visitors (no authentication required) +- **Layout**: Public header with login/signup, footer with links +- **Routes**: + - `/` - Home page with features and CTAs + - `/races` - Browse and search races + - `/about` - About the platform + - `/login` - Login page + - `/signup` - Registration page + +#### Protected Routes (`_layout/`) +- **Access**: Authenticated users only (redirects to login if not authenticated) +- **Layout**: Dashboard sidebar, user menu +- **Routes by Role**: + - **Admin**: Full access to all features + - User management (`/admin`) + - Role management + - System settings + - **Organizer**: Race management features + - Create/edit races + - Manage registrations + - View participant lists + - **Runner**: Personal dashboard + - Profile management (`/settings`) + - Race history + - Register for races + - **Volunteer**: Race day operations + - Check-in interface + - Race information + +### API Client Usage + +```typescript +import { UsersService, type UserPublic } from "@/client" +import { useQuery } from "@tanstack/react-query" +import useAuth from "@/hooks/useAuth" + +function MyComponent() { + const { user, logout } = useAuth() + + // Check user role + const isAdmin = user?.roles.some(role => role.name === 'admin') + const isRunner = user?.roles.some(role => role.name === 'runner') + + // Use auto-generated API client + const { data } = useQuery({ + queryKey: ["currentUser"], + queryFn: UsersService.readUserMe, + }) +} +``` + +### Role-Based UI Components + +```typescript +import useAuth from "@/hooks/useAuth" + +function RoleBasedComponent() { + const { user } = useAuth() + + const hasRole = (roleName: string) => + user?.roles.some(role => role.name === roleName) || user?.is_superuser + + return ( + <> + {hasRole("admin") && } + {hasRole("organizer") && } + {hasRole("runner") && } + + ) +} +``` + +## Initial Setup + +When the database is initialized (`python app/initial_data.py`): + +1. Four default roles are created +2. First superuser is created from environment variables +3. Admin role is automatically assigned to the superuser + +## Migration + +The RBAC system was added via Alembic migration: +- Migration file: `backend/app/alembic/versions/5280d245c748_add_rbac_with_roles_and_user_role_.py` +- Creates `role` and `userrolelink` tables +- Run: `alembic upgrade head` + +## Environment Variables + +Key variables (in `.env` file at project root): +- `PROJECT_NAME` - Application name +- `SECRET_KEY` - JWT signing key +- `POSTGRES_*` - Database connection details +- `FIRST_SUPERUSER*` - Initial admin user +- `SMTP_*` - Email configuration +- `FRONTEND_HOST` - Frontend URL for CORS + +## Docker Deployment + +The updated `compose.yml` includes two main services: + +1. **frontend**: React application serving both public and authenticated routes +2. **backend**: FastAPI API server + +To deploy: + +```bash +# Development with hot reload +docker compose watch + +# Production +docker compose up -d +``` + +Access points: +- Frontend: `http://localhost:5173` (development) or configured domain (production) +- Backend API: `http://localhost:8000` +- API Docs: `http://localhost:8000/docs` + +## Best Practices + +### 1. Always Use Superuser Override Pattern +```python +# Good +if not current_user.is_superuser and not crud.user_has_role(current_user, "admin"): + raise HTTPException(...) + +# Better (built into dependencies) +AdminUser = Annotated[User, Depends(require_role("admin"))] +``` + +### 2. Assign Default Roles on Registration +```python +# When creating new user +user = crud.create_user(session=session, user_create=user_in, default_role="runner") +``` + +### 3. Check Roles in Frontend +```typescript +// Hide/show UI elements based on role +{user?.roles.some(r => r.name === 'admin') && ( + +)} +``` + +### 4. Use Role-Specific Endpoints +- Create separate endpoint groups for different user types +- Use dependencies to enforce access control +- Return appropriate data based on user role + +## Testing RBAC + +### Create Test Users with Roles + +```python +# In tests +user = crud.create_user(session=db, user_create=user_data, default_role="runner") + +# Assign additional roles +admin_role = crud.get_role_by_name(session=db, name="admin") +crud.assign_role_to_user(session=db, user=user, role=admin_role) +``` + +### Test Permission Checks + +```python +def test_admin_only_endpoint(client, normal_user_token_headers): + """Test that non-admin users cannot access admin endpoints.""" + response = client.get("/api/v1/admin-endpoint", headers=normal_user_token_headers) + assert response.status_code == 403 + +def test_admin_can_access(client, superuser_token_headers): + """Test that admin users can access admin endpoints.""" + response = client.get("/api/v1/admin-endpoint", headers=superuser_token_headers) + assert response.status_code == 200 +``` + +## Future Enhancements + +Possible extensions to the RBAC system: + +1. **Granular Permissions**: Add permission-level control (e.g., `race.create`, `race.edit`) +2. **Role Hierarchy**: Implement role inheritance +3. **Dynamic Roles**: Allow creating custom roles via API +4. **Audit Logging**: Track role assignments and permission changes +5. **Time-Based Roles**: Temporary role assignments with expiration +6. **Resource-Level Permissions**: Per-resource access control (e.g., edit only owned races) + +## Troubleshooting + +### User Can't Access Endpoint +1. Check if user has required role: `SELECT * FROM userrolelink WHERE user_id = 'xxx'` +2. Verify role exists: `SELECT * FROM role WHERE name = 'runner'` +3. Check if `is_superuser` should be true +4. Review endpoint dependencies + +### Role Not Assigned on Registration +1. Verify default role exists in database +2. Check `create_user` function is called with `default_role` parameter +3. Ensure `init_db` has run to create default roles + +### Frontend Not Showing Roles +1. Verify `/api/v1/users/me` returns roles array +2. Check `roles` relationship is loaded in SQLModel query +3. Ensure API client includes roles in User type + +## API Examples + +### Assign Runner Role to New User + +```bash +# Get runner role ID +curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/api/v1/roles/ + +# Assign role to user +curl -X POST \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + http://localhost:8000/api/v1/roles/{role_id}/assign/{user_id} +``` + +### Check Current User's Roles + +```bash +curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/api/v1/users/me +``` + +Response includes `roles` array: +```json +{ + "id": "xxx", + "email": "user@example.com", + "full_name": "John Doe", + "roles": [ + { + "id": "yyy", + "name": "runner", + "description": "Regular user who can register for races" + } + ] +} +``` diff --git a/backend/app/alembic/versions/5280d245c748_add_rbac_with_roles_and_user_role_.py b/backend/app/alembic/versions/5280d245c748_add_rbac_with_roles_and_user_role_.py new file mode 100644 index 0000000000..cf6835d5d5 --- /dev/null +++ b/backend/app/alembic/versions/5280d245c748_add_rbac_with_roles_and_user_role_.py @@ -0,0 +1,45 @@ +"""Add RBAC with roles and user-role relationship + +Revision ID: 5280d245c748 +Revises: fe56fa70289e +Create Date: 2026-03-28 22:01:13.342027 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = '5280d245c748' +down_revision = 'fe56fa70289e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('role', + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_role_name'), 'role', ['name'], unique=True) + op.create_table('userrolelink', + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('role_id', sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(['role_id'], ['role.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('user_id', 'role_id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('userrolelink') + op.drop_index(op.f('ix_role_name'), table_name='role') + op.drop_table('role') + # ### end Alembic commands ### diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index c2b83c841d..a1fa96cb79 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -8,6 +8,7 @@ from pydantic import ValidationError from sqlmodel import Session +from app import crud from app.core import security from app.core.config import settings from app.core.db import engine @@ -55,3 +56,38 @@ def get_current_active_superuser(current_user: CurrentUser) -> User: status_code=403, detail="The user doesn't have enough privileges" ) return current_user + + +# Role-based permission checking +def require_role(required_role: str): + """Dependency factory to require a specific role.""" + def check_role(current_user: CurrentUser) -> User: + if not crud.user_has_role(current_user, required_role) and not current_user.is_superuser: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"User does not have required role: {required_role}" + ) + return current_user + return check_role + + +def require_any_role(required_roles: list[str]): + """Dependency factory to require any of the specified roles.""" + def check_roles(current_user: CurrentUser) -> User: + if not crud.user_has_any_role(current_user, required_roles) and not current_user.is_superuser: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"User does not have any of the required roles: {', '.join(required_roles)}" + ) + return current_user + return check_roles + + +# Predefined role dependencies for common use cases +AdminUser = Annotated[User, Depends(require_role("admin"))] +RunnerUser = Annotated[User, Depends(require_role("runner"))] +OrganizerUser = Annotated[User, Depends(require_role("organizer"))] +VolunteerUser = Annotated[User, Depends(require_role("volunteer"))] + +# Combined role dependencies +AdminOrOrganizerUser = Annotated[User, Depends(require_any_role(["admin", "organizer"]))] diff --git a/backend/app/api/main.py b/backend/app/api/main.py index eac18c8e8f..2ae51a0ca9 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,11 +1,12 @@ from fastapi import APIRouter -from app.api.routes import items, login, private, users, utils +from app.api.routes import items, login, private, roles, users, utils from app.core.config import settings api_router = APIRouter() api_router.include_router(login.router) api_router.include_router(users.router) +api_router.include_router(roles.router) api_router.include_router(utils.router) api_router.include_router(items.router) diff --git a/backend/app/api/routes/roles.py b/backend/app/api/routes/roles.py new file mode 100644 index 0000000000..68a66922db --- /dev/null +++ b/backend/app/api/routes/roles.py @@ -0,0 +1,182 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import func, select + +from app import crud +from app.api.deps import SessionDep, get_current_active_superuser +from app.models import ( + Message, + Role, + RoleCreate, + RolePublic, + RolesPublic, + RoleUpdate, + User, + UserPublic, +) + +router = APIRouter(prefix="/roles", tags=["roles"]) + + +@router.get( + "/", + dependencies=[Depends(get_current_active_superuser)], + response_model=RolesPublic, +) +def read_roles( + session: SessionDep, + skip: int = 0, + limit: int = 100, +) -> Any: + """ + Retrieve roles. Admin only. + """ + count_statement = select(func.count()).select_from(Role) + count = session.exec(count_statement).one() + + statement = select(Role).offset(skip).limit(limit) + roles = session.exec(statement).all() + + return RolesPublic( + data=[RolePublic.model_validate(role) for role in roles], count=count + ) + + +@router.get( + "/{role_id}", + dependencies=[Depends(get_current_active_superuser)], + response_model=RolePublic, +) +def read_role(session: SessionDep, role_id: uuid.UUID) -> Any: + """ + Get role by ID. Admin only. + """ + role = session.get(Role, role_id) + if not role: + raise HTTPException(status_code=404, detail="Role not found") + return role + + +@router.post( + "/", + dependencies=[Depends(get_current_active_superuser)], + response_model=RolePublic, +) +def create_role(*, session: SessionDep, role_in: RoleCreate) -> Any: + """ + Create new role. Admin only. + """ + # Check if role already exists + existing_role = crud.get_role_by_name(session=session, name=role_in.name) + if existing_role: + raise HTTPException( + status_code=400, + detail="A role with this name already exists", + ) + + role = crud.create_role(session=session, role_create=role_in) + return role + + +@router.put( + "/{role_id}", + dependencies=[Depends(get_current_active_superuser)], + response_model=RolePublic, +) +def update_role( + *, + session: SessionDep, + role_id: uuid.UUID, + role_in: RoleUpdate, +) -> Any: + """ + Update a role. Admin only. + """ + role = session.get(Role, role_id) + if not role: + raise HTTPException(status_code=404, detail="Role not found") + + # Check if new name conflicts with existing role + if role_in.name and role_in.name != role.name: + existing_role = crud.get_role_by_name(session=session, name=role_in.name) + if existing_role: + raise HTTPException( + status_code=400, + detail="A role with this name already exists", + ) + + update_dict = role_in.model_dump(exclude_unset=True) + role.sqlmodel_update(update_dict) + session.add(role) + session.commit() + session.refresh(role) + return role + + +@router.delete( + "/{role_id}", + dependencies=[Depends(get_current_active_superuser)], +) +def delete_role(session: SessionDep, role_id: uuid.UUID) -> Message: + """ + Delete a role. Admin only. + """ + role = session.get(Role, role_id) + if not role: + raise HTTPException(status_code=404, detail="Role not found") + + session.delete(role) + session.commit() + return Message(message="Role deleted successfully") + + +@router.post( + "/{role_id}/assign/{user_id}", + dependencies=[Depends(get_current_active_superuser)], + response_model=UserPublic, +) +def assign_role_to_user( + session: SessionDep, + role_id: uuid.UUID, + user_id: uuid.UUID, +) -> Any: + """ + Assign a role to a user. Admin only. + """ + role = session.get(Role, role_id) + if not role: + raise HTTPException(status_code=404, detail="Role not found") + + user = session.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + user = crud.assign_role_to_user(session=session, user=user, role=role) + return user + + +@router.delete( + "/{role_id}/remove/{user_id}", + dependencies=[Depends(get_current_active_superuser)], + response_model=UserPublic, +) +def remove_role_from_user( + session: SessionDep, + role_id: uuid.UUID, + user_id: uuid.UUID, +) -> Any: + """ + Remove a role from a user. Admin only. + """ + role = session.get(Role, role_id) + if not role: + raise HTTPException(status_code=404, detail="Role not found") + + user = session.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + user = crud.remove_role_from_user(session=session, user=user, role=role) + return user diff --git a/backend/app/core/db.py b/backend/app/core/db.py index ba991fb36d..9c9564b2b1 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -2,7 +2,7 @@ from app import crud from app.core.config import settings -from app.models import User, UserCreate +from app.models import RoleEnum, User, UserCreate engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) @@ -21,6 +21,22 @@ def init_db(session: Session) -> None: # This works because the models are already imported and registered from app.models # SQLModel.metadata.create_all(engine) + # Create default roles + roles_to_create = [ + (RoleEnum.ADMIN, "System administrator with full access"), + (RoleEnum.RUNNER, "Regular user who can register for races"), + (RoleEnum.ORGANIZER, "Race organizer who can create and manage races"), + (RoleEnum.VOLUNTEER, "Volunteer who can help with race operations"), + ] + + for role_name, description in roles_to_create: + crud.get_or_create_role( + session=session, + role_name=role_name.value, + description=description + ) + + # Create first superuser user = session.exec( select(User).where(User.email == settings.FIRST_SUPERUSER) ).first() @@ -30,4 +46,9 @@ def init_db(session: Session) -> None: password=settings.FIRST_SUPERUSER_PASSWORD, is_superuser=True, ) - user = crud.create_user(session=session, user_create=user_in) + user = crud.create_user(session=session, user_create=user_in, default_role=None) + + # Assign admin role to superuser + admin_role = crud.get_role_by_name(session=session, name=RoleEnum.ADMIN.value) + if admin_role: + crud.assign_role_to_user(session=session, user=user, role=admin_role) diff --git a/backend/app/crud.py b/backend/app/crud.py index a8ceba6444..9f669314cf 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -4,16 +4,81 @@ from sqlmodel import Session, select from app.core.security import get_password_hash, verify_password -from app.models import Item, ItemCreate, User, UserCreate, UserUpdate +from app.models import Item, ItemCreate, Role, RoleCreate, User, UserCreate, UserUpdate -def create_user(*, session: Session, user_create: UserCreate) -> User: +# Role CRUD operations +def create_role(*, session: Session, role_create: RoleCreate) -> Role: + db_obj = Role.model_validate(role_create) + session.add(db_obj) + session.commit() + session.refresh(db_obj) + return db_obj + + +def get_role_by_name(*, session: Session, name: str) -> Role | None: + statement = select(Role).where(Role.name == name) + return session.exec(statement).first() + + +def get_or_create_role(*, session: Session, role_name: str, description: str | None = None) -> Role: + """Get existing role or create it if it doesn't exist.""" + role = get_role_by_name(session=session, name=role_name) + if not role: + role = create_role( + session=session, + role_create=RoleCreate(name=role_name, description=description) + ) + return role + + +def assign_role_to_user(*, session: Session, user: User, role: Role) -> User: + """Assign a role to a user.""" + if role not in user.roles: + user.roles.append(role) + session.add(user) + session.commit() + session.refresh(user) + return user + + +def remove_role_from_user(*, session: Session, user: User, role: Role) -> User: + """Remove a role from a user.""" + if role in user.roles: + user.roles.remove(role) + session.add(user) + session.commit() + session.refresh(user) + return user + + +def user_has_role(user: User, role_name: str) -> bool: + """Check if user has a specific role.""" + return any(role.name == role_name for role in user.roles) + + +def user_has_any_role(user: User, role_names: list[str]) -> bool: + """Check if user has any of the specified roles.""" + return any(role.name in role_names for role in user.roles) + + +# User CRUD operations + + +def create_user(*, session: Session, user_create: UserCreate, default_role: str | None = "runner") -> User: db_obj = User.model_validate( user_create, update={"hashed_password": get_password_hash(user_create.password)} ) session.add(db_obj) session.commit() session.refresh(db_obj) + + # Assign default role if specified + if default_role: + role = get_role_by_name(session=session, name=default_role) + if role: + assign_role_to_user(session=session, user=db_obj, role=role) + return db_obj diff --git a/backend/app/models.py b/backend/app/models.py index b5132e0e2c..bba0a8c2e6 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,8 +1,9 @@ import uuid from datetime import datetime, timezone +from enum import Enum from pydantic import EmailStr -from sqlalchemy import DateTime +from sqlalchemy import Column, DateTime from sqlmodel import Field, Relationship, SQLModel @@ -10,6 +11,58 @@ def get_datetime_utc() -> datetime: return datetime.now(timezone.utc) +# Enum for predefined roles +class RoleEnum(str, Enum): + ADMIN = "admin" + RUNNER = "runner" + ORGANIZER = "organizer" + VOLUNTEER = "volunteer" + + +# Link table for many-to-many relationship between User and Role +class UserRoleLink(SQLModel, table=True): + user_id: uuid.UUID = Field( + foreign_key="user.id", primary_key=True, ondelete="CASCADE" + ) + role_id: uuid.UUID = Field( + foreign_key="role.id", primary_key=True, ondelete="CASCADE" + ) + + +# Role model +class RoleBase(SQLModel): + name: str = Field(unique=True, index=True, max_length=50) + description: str | None = Field(default=None, max_length=255) + + +class RoleCreate(RoleBase): + pass + + +class RoleUpdate(SQLModel): + name: str | None = Field(default=None, max_length=50) + description: str | None = Field(default=None, max_length=255) + + +class Role(RoleBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + created_at: datetime | None = Field( + default_factory=get_datetime_utc, + sa_column=Column(DateTime(timezone=True)), + ) + users: list["User"] = Relationship(back_populates="roles", link_model=UserRoleLink) + + +class RolePublic(RoleBase): + id: uuid.UUID + created_at: datetime | None = None + + +class RolesPublic(SQLModel): + data: list[RolePublic] + count: int + + # Shared properties class UserBase(SQLModel): email: EmailStr = Field(unique=True, index=True, max_length=255) @@ -31,7 +84,7 @@ class UserRegister(SQLModel): # Properties to receive via API on update, all are optional class UserUpdate(UserBase): - email: EmailStr | None = Field(default=None, max_length=255) # type: ignore + email: EmailStr | None = Field(default=None, max_length=255) password: str | None = Field(default=None, min_length=8, max_length=128) @@ -51,15 +104,17 @@ class User(UserBase, table=True): hashed_password: str created_at: datetime | None = Field( default_factory=get_datetime_utc, - sa_type=DateTime(timezone=True), # type: ignore + sa_column=Column(DateTime(timezone=True)), ) items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True) + roles: list[Role] = Relationship(back_populates="users", link_model=UserRoleLink) # Properties to return via API, id is always required class UserPublic(UserBase): id: uuid.UUID created_at: datetime | None = None + roles: list[RolePublic] = [] class UsersPublic(SQLModel): @@ -80,7 +135,7 @@ class ItemCreate(ItemBase): # Properties to receive on item update class ItemUpdate(ItemBase): - title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore + title: str | None = Field(default=None, min_length=1, max_length=255) # Database model, database table inferred from class name @@ -88,7 +143,7 @@ class Item(ItemBase, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) created_at: datetime | None = Field( default_factory=get_datetime_utc, - sa_type=DateTime(timezone=True), # type: ignore + sa_column=Column(DateTime(timezone=True)), ) owner_id: uuid.UUID = Field( foreign_key="user.id", nullable=False, ondelete="CASCADE" diff --git a/compose.yml b/compose.yml index 2488fc007b..ec661e3aa6 100644 --- a/compose.yml +++ b/compose.yml @@ -136,6 +136,38 @@ services: # Enable redirection for HTTP and HTTPS - traefik.http.routers.${STACK_NAME?Variable not set}-backend-http.middlewares=https-redirect + runner-site: + image: '${DOCKER_IMAGE_RUNNER_SITE?Variable not set}:${TAG-latest}' + build: + context: ./runner-site + dockerfile: Dockerfile + restart: always + networks: + - traefik-public + - default + depends_on: + - backend + environment: + - NEXT_PUBLIC_API_URL=http://backend:8000/api/v1 + - NEXT_PUBLIC_SITE_URL=${RUNNER_SITE_HOST?Variable not set} + labels: + - traefik.enable=true + - traefik.docker.network=traefik-public + - traefik.constraint-label=traefik-public + + - traefik.http.services.${STACK_NAME?Variable not set}-runner-site.loadbalancer.server.port=3000 + + - traefik.http.routers.${STACK_NAME?Variable not set}-runner-site-http.rule=Host(`${DOMAIN?Variable not set}`) + - traefik.http.routers.${STACK_NAME?Variable not set}-runner-site-http.entrypoints=http + + - traefik.http.routers.${STACK_NAME?Variable not set}-runner-site-https.rule=Host(`${DOMAIN?Variable not set}`) + - traefik.http.routers.${STACK_NAME?Variable not set}-runner-site-https.entrypoints=https + - traefik.http.routers.${STACK_NAME?Variable not set}-runner-site-https.tls=true + - traefik.http.routers.${STACK_NAME?Variable not set}-runner-site-https.tls.certresolver=le + + # Enable redirection for HTTP and HTTPS + - traefik.http.routers.${STACK_NAME?Variable not set}-runner-site-http.middlewares=https-redirect + frontend: image: '${DOCKER_IMAGE_FRONTEND?Variable not set}:${TAG-latest}' restart: always diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index fb66c1f837..f61c1f788a 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -251,6 +251,123 @@ export const PrivateUserCreateSchema = { title: 'PrivateUserCreate' } as const; +export const RoleCreateSchema = { + properties: { + name: { + type: 'string', + maxLength: 50, + title: 'Name' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Description' + } + }, + type: 'object', + required: ['name'], + title: 'RoleCreate' +} as const; + +export const RolePublicSchema = { + properties: { + name: { + type: 'string', + maxLength: 50, + title: 'Name' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + created_at: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Created At' + } + }, + type: 'object', + required: ['name', 'id'], + title: 'RolePublic' +} as const; + +export const RoleUpdateSchema = { + properties: { + name: { + anyOf: [ + { + type: 'string', + maxLength: 50 + }, + { + type: 'null' + } + ], + title: 'Name' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Description' + } + }, + type: 'object', + title: 'RoleUpdate' +} as const; + +export const RolesPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/RolePublic' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'RolesPublic' +} as const; + export const TokenSchema = { properties: { access_token: { @@ -376,6 +493,14 @@ export const UserPublicSchema = { } ], title: 'Created At' + }, + roles: { + items: { + '$ref': '#/components/schemas/RolePublic' + }, + type: 'array', + title: 'Roles', + default: [] } }, type: 'object', diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index ba79e3f726..75726ecfea 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen'; +import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, RolesReadRolesData, RolesReadRolesResponse, RolesCreateRoleData, RolesCreateRoleResponse, RolesReadRoleData, RolesReadRoleResponse, RolesUpdateRoleData, RolesUpdateRoleResponse, RolesDeleteRoleData, RolesDeleteRoleResponse, RolesAssignRoleToUserData, RolesAssignRoleToUserResponse, RolesRemoveRoleFromUserData, RolesRemoveRoleFromUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen'; export class ItemsService { /** @@ -235,6 +235,163 @@ export class PrivateService { } } +export class RolesService { + /** + * Read Roles + * Retrieve roles. Admin only. + * @param data The data for the request. + * @param data.skip + * @param data.limit + * @returns RolesPublic Successful Response + * @throws ApiError + */ + public static readRoles(data: RolesReadRolesData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/roles/', + query: { + skip: data.skip, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Create Role + * Create new role. Admin only. + * @param data The data for the request. + * @param data.requestBody + * @returns RolePublic Successful Response + * @throws ApiError + */ + public static createRole(data: RolesCreateRoleData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/roles/', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Role + * Get role by ID. Admin only. + * @param data The data for the request. + * @param data.roleId + * @returns RolePublic Successful Response + * @throws ApiError + */ + public static readRole(data: RolesReadRoleData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/roles/{role_id}', + path: { + role_id: data.roleId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update Role + * Update a role. Admin only. + * @param data The data for the request. + * @param data.roleId + * @param data.requestBody + * @returns RolePublic Successful Response + * @throws ApiError + */ + public static updateRole(data: RolesUpdateRoleData): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/roles/{role_id}', + path: { + role_id: data.roleId + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Delete Role + * Delete a role. Admin only. + * @param data The data for the request. + * @param data.roleId + * @returns Message Successful Response + * @throws ApiError + */ + public static deleteRole(data: RolesDeleteRoleData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/roles/{role_id}', + path: { + role_id: data.roleId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Assign Role To User + * Assign a role to a user. Admin only. + * @param data The data for the request. + * @param data.roleId + * @param data.userId + * @returns UserPublic Successful Response + * @throws ApiError + */ + public static assignRoleToUser(data: RolesAssignRoleToUserData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/roles/{role_id}/assign/{user_id}', + path: { + role_id: data.roleId, + user_id: data.userId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Remove Role From User + * Remove a role from a user. Admin only. + * @param data The data for the request. + * @param data.roleId + * @param data.userId + * @returns UserPublic Successful Response + * @throws ApiError + */ + public static removeRoleFromUser(data: RolesRemoveRoleFromUserData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/roles/{role_id}/remove/{user_id}', + path: { + role_id: data.roleId, + user_id: data.userId + }, + errors: { + 422: 'Validation Error' + } + }); + } +} + export class UsersService { /** * Read Users diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 91b5ba34c2..f4b220d65e 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -52,6 +52,28 @@ export type PrivateUserCreate = { is_verified?: boolean; }; +export type RoleCreate = { + name: string; + description?: (string | null); +}; + +export type RolePublic = { + name: string; + description?: (string | null); + id: string; + created_at?: (string | null); +}; + +export type RolesPublic = { + data: Array; + count: number; +}; + +export type RoleUpdate = { + name?: (string | null); + description?: (string | null); +}; + export type Token = { access_token: string; token_type?: string; @@ -77,6 +99,7 @@ export type UserPublic = { full_name?: (string | null); id: string; created_at?: (string | null); + roles?: Array; }; export type UserRegister = { @@ -177,6 +200,52 @@ export type PrivateCreateUserData = { export type PrivateCreateUserResponse = (UserPublic); +export type RolesReadRolesData = { + limit?: number; + skip?: number; +}; + +export type RolesReadRolesResponse = (RolesPublic); + +export type RolesCreateRoleData = { + requestBody: RoleCreate; +}; + +export type RolesCreateRoleResponse = (RolePublic); + +export type RolesReadRoleData = { + roleId: string; +}; + +export type RolesReadRoleResponse = (RolePublic); + +export type RolesUpdateRoleData = { + requestBody: RoleUpdate; + roleId: string; +}; + +export type RolesUpdateRoleResponse = (RolePublic); + +export type RolesDeleteRoleData = { + roleId: string; +}; + +export type RolesDeleteRoleResponse = (Message); + +export type RolesAssignRoleToUserData = { + roleId: string; + userId: string; +}; + +export type RolesAssignRoleToUserResponse = (UserPublic); + +export type RolesRemoveRoleFromUserData = { + roleId: string; + userId: string; +}; + +export type RolesRemoveRoleFromUserResponse = (UserPublic); + export type UsersReadUsersData = { limit?: number; skip?: number; diff --git a/frontend/src/components/Public/PublicFooter.tsx b/frontend/src/components/Public/PublicFooter.tsx new file mode 100644 index 0000000000..a392c2ac21 --- /dev/null +++ b/frontend/src/components/Public/PublicFooter.tsx @@ -0,0 +1,100 @@ +import { Link } from "@tanstack/react-router" + +const footerLinks = { + product: [ + { label: "Races", to: "/races" }, + { label: "About", to: "/about" }, + { label: "Contact", to: "/contact" }, + ], + legal: [ + { label: "Privacy Policy", to: "/privacy" }, + { label: "Terms of Service", to: "/terms" }, + ], +} + +export function PublicFooter() { + const currentYear = new Date().getFullYear() + + return ( +
+
+
+ {/* Brand */} +
+

RaceHub

+

+ Find and register for races near you. Built with FastAPI Full-Stack Template. +

+
+ + {/* Product Links */} +
+

Product

+
    + {footerLinks.product.map(({ label, to }) => ( +
  • + + {label} + +
  • + ))} +
+
+ + {/* Legal Links */} +
+

Legal

+
    + {footerLinks.legal.map(({ label, to }) => ( +
  • + + {label} + +
  • + ))} +
+
+ + {/* Resources */} +
+

Resources

+ +
+
+ +
+

+ © {currentYear} RaceHub. All rights reserved. +

+
+
+
+ ) +} diff --git a/frontend/src/components/Public/PublicHeader.tsx b/frontend/src/components/Public/PublicHeader.tsx new file mode 100644 index 0000000000..7286f87768 --- /dev/null +++ b/frontend/src/components/Public/PublicHeader.tsx @@ -0,0 +1,105 @@ +import { Link } from "@tanstack/react-router" +import { Menu } from "lucide-react" +import { Button } from "@/components/ui/button" +import { + Sheet, + SheetContent, + SheetTrigger, +} from "@/components/ui/sheet" +import { isLoggedIn } from "@/hooks/useAuth" + +const navLinks = [ + { to: "/", label: "Home" }, + { to: "/races", label: "Races" }, + { to: "/about", label: "About" }, +] + +export function PublicHeader() { + const loggedIn = isLoggedIn() + + return ( +
+
+ {/* Logo and Desktop Navigation */} +
+ + RaceHub + + + +
+ + {/* Desktop Auth Buttons */} +
+ {loggedIn ? ( + + ) : ( + <> + + + + )} +
+ + {/* Mobile Menu */} + + + + + + + + +
+
+ ) +} diff --git a/frontend/src/components/Sidebar/AppSidebar.tsx b/frontend/src/components/Sidebar/AppSidebar.tsx index 8502bcb9a4..a8fda382ed 100644 --- a/frontend/src/components/Sidebar/AppSidebar.tsx +++ b/frontend/src/components/Sidebar/AppSidebar.tsx @@ -1,4 +1,4 @@ -import { Briefcase, Home, Users } from "lucide-react" +import { Briefcase, Home, Settings, Users } from "lucide-react" import { SidebarAppearance } from "@/components/Common/Appearance" import { Logo } from "@/components/Common/Logo" @@ -12,17 +12,17 @@ import useAuth from "@/hooks/useAuth" import { type Item, Main } from "./Main" import { User } from "./User" -const baseItems: Item[] = [ - { icon: Home, title: "Dashboard", path: "/" }, - { icon: Briefcase, title: "Items", path: "/items" }, +const adminItems: Item[] = [ + { icon: Home, title: "Dashboard", path: "/admin/dashboard" }, + { icon: Users, title: "Users", path: "/admin/users" }, + { icon: Briefcase, title: "Items", path: "/admin/items" }, + { icon: Settings, title: "Settings", path: "/admin/settings" }, ] export function AppSidebar() { const { user: currentUser } = useAuth() - const items = currentUser?.is_superuser - ? [...baseItems, { icon: Users, title: "Admin", path: "/admin" }] - : baseItems + const items = currentUser?.is_superuser ? adminItems : [] return ( diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 7ccc795c48..45a0394acc 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -48,7 +48,7 @@ const useAuth = () => { const loginMutation = useMutation({ mutationFn: login, onSuccess: () => { - navigate({ to: "/" }) + navigate({ to: "/admin/dashboard" }) }, onError: handleError.bind(showErrorToast), }) diff --git a/frontend/src/index.css b/frontend/src/index.css index 47e56960ad..fd40709d00 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -3,6 +3,46 @@ @custom-variant dark (&:is(.dark *)); +@layer utilities { + .container { + width: 100%; + margin-left: auto; + margin-right: auto; + padding-left: 1rem; + padding-right: 1rem; + } + + @media (min-width: 640px) { + .container { + max-width: 640px; + } + } + + @media (min-width: 768px) { + .container { + max-width: 768px; + } + } + + @media (min-width: 1024px) { + .container { + max-width: 1024px; + } + } + + @media (min-width: 1280px) { + .container { + max-width: 1280px; + } + } + + @media (min-width: 1536px) { + .container { + max-width: 1536px; + } + } +} + @theme inline { --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 08d665fef8..5058f56c31 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -13,11 +13,17 @@ import { Route as SignupRouteImport } from './routes/signup' import { Route as ResetPasswordRouteImport } from './routes/reset-password' import { Route as RecoverPasswordRouteImport } from './routes/recover-password' import { Route as LoginRouteImport } from './routes/login' +import { Route as PublicRouteImport } from './routes/_public' import { Route as LayoutRouteImport } from './routes/_layout' -import { Route as LayoutIndexRouteImport } from './routes/_layout/index' -import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings' -import { Route as LayoutItemsRouteImport } from './routes/_layout/items' -import { Route as LayoutAdminRouteImport } from './routes/_layout/admin' +import { Route as PublicIndexRouteImport } from './routes/_public/index' +import { Route as PublicRacesRouteImport } from './routes/_public/races' +import { Route as PublicAboutRouteImport } from './routes/_public/about' +import { Route as LayoutAdminRouteImport } from './routes/_layout.admin' +import { Route as LayoutAdminIndexRouteImport } from './routes/_layout.admin/index' +import { Route as LayoutAdminUsersRouteImport } from './routes/_layout.admin/users' +import { Route as LayoutAdminSettingsRouteImport } from './routes/_layout.admin/settings' +import { Route as LayoutAdminItemsRouteImport } from './routes/_layout.admin/items' +import { Route as LayoutAdminDashboardRouteImport } from './routes/_layout.admin/dashboard' const SignupRoute = SignupRouteImport.update({ id: '/signup', @@ -39,62 +45,106 @@ const LoginRoute = LoginRouteImport.update({ path: '/login', getParentRoute: () => rootRouteImport, } as any) +const PublicRoute = PublicRouteImport.update({ + id: '/_public', + getParentRoute: () => rootRouteImport, +} as any) const LayoutRoute = LayoutRouteImport.update({ id: '/_layout', getParentRoute: () => rootRouteImport, } as any) -const LayoutIndexRoute = LayoutIndexRouteImport.update({ +const PublicIndexRoute = PublicIndexRouteImport.update({ id: '/', path: '/', + getParentRoute: () => PublicRoute, +} as any) +const PublicRacesRoute = PublicRacesRouteImport.update({ + id: '/races', + path: '/races', + getParentRoute: () => PublicRoute, +} as any) +const PublicAboutRoute = PublicAboutRouteImport.update({ + id: '/about', + path: '/about', + getParentRoute: () => PublicRoute, +} as any) +const LayoutAdminRoute = LayoutAdminRouteImport.update({ + id: '/admin', + path: '/admin', getParentRoute: () => LayoutRoute, } as any) -const LayoutSettingsRoute = LayoutSettingsRouteImport.update({ +const LayoutAdminIndexRoute = LayoutAdminIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => LayoutAdminRoute, +} as any) +const LayoutAdminUsersRoute = LayoutAdminUsersRouteImport.update({ + id: '/users', + path: '/users', + getParentRoute: () => LayoutAdminRoute, +} as any) +const LayoutAdminSettingsRoute = LayoutAdminSettingsRouteImport.update({ id: '/settings', path: '/settings', - getParentRoute: () => LayoutRoute, + getParentRoute: () => LayoutAdminRoute, } as any) -const LayoutItemsRoute = LayoutItemsRouteImport.update({ +const LayoutAdminItemsRoute = LayoutAdminItemsRouteImport.update({ id: '/items', path: '/items', - getParentRoute: () => LayoutRoute, + getParentRoute: () => LayoutAdminRoute, } as any) -const LayoutAdminRoute = LayoutAdminRouteImport.update({ - id: '/admin', - path: '/admin', - getParentRoute: () => LayoutRoute, +const LayoutAdminDashboardRoute = LayoutAdminDashboardRouteImport.update({ + id: '/dashboard', + path: '/dashboard', + getParentRoute: () => LayoutAdminRoute, } as any) export interface FileRoutesByFullPath { - '/': typeof LayoutIndexRoute + '/': typeof PublicIndexRoute '/login': typeof LoginRoute '/recover-password': typeof RecoverPasswordRoute '/reset-password': typeof ResetPasswordRoute '/signup': typeof SignupRoute - '/admin': typeof LayoutAdminRoute - '/items': typeof LayoutItemsRoute - '/settings': typeof LayoutSettingsRoute + '/admin': typeof LayoutAdminRouteWithChildren + '/about': typeof PublicAboutRoute + '/races': typeof PublicRacesRoute + '/admin/dashboard': typeof LayoutAdminDashboardRoute + '/admin/items': typeof LayoutAdminItemsRoute + '/admin/settings': typeof LayoutAdminSettingsRoute + '/admin/users': typeof LayoutAdminUsersRoute + '/admin/': typeof LayoutAdminIndexRoute } export interface FileRoutesByTo { + '/': typeof PublicIndexRoute '/login': typeof LoginRoute '/recover-password': typeof RecoverPasswordRoute '/reset-password': typeof ResetPasswordRoute '/signup': typeof SignupRoute - '/admin': typeof LayoutAdminRoute - '/items': typeof LayoutItemsRoute - '/settings': typeof LayoutSettingsRoute - '/': typeof LayoutIndexRoute + '/about': typeof PublicAboutRoute + '/races': typeof PublicRacesRoute + '/admin/dashboard': typeof LayoutAdminDashboardRoute + '/admin/items': typeof LayoutAdminItemsRoute + '/admin/settings': typeof LayoutAdminSettingsRoute + '/admin/users': typeof LayoutAdminUsersRoute + '/admin': typeof LayoutAdminIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/_layout': typeof LayoutRouteWithChildren + '/_public': typeof PublicRouteWithChildren '/login': typeof LoginRoute '/recover-password': typeof RecoverPasswordRoute '/reset-password': typeof ResetPasswordRoute '/signup': typeof SignupRoute - '/_layout/admin': typeof LayoutAdminRoute - '/_layout/items': typeof LayoutItemsRoute - '/_layout/settings': typeof LayoutSettingsRoute - '/_layout/': typeof LayoutIndexRoute + '/_layout/admin': typeof LayoutAdminRouteWithChildren + '/_public/about': typeof PublicAboutRoute + '/_public/races': typeof PublicRacesRoute + '/_public/': typeof PublicIndexRoute + '/_layout/admin/dashboard': typeof LayoutAdminDashboardRoute + '/_layout/admin/items': typeof LayoutAdminItemsRoute + '/_layout/admin/settings': typeof LayoutAdminSettingsRoute + '/_layout/admin/users': typeof LayoutAdminUsersRoute + '/_layout/admin/': typeof LayoutAdminIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -105,33 +155,49 @@ export interface FileRouteTypes { | '/reset-password' | '/signup' | '/admin' - | '/items' - | '/settings' + | '/about' + | '/races' + | '/admin/dashboard' + | '/admin/items' + | '/admin/settings' + | '/admin/users' + | '/admin/' fileRoutesByTo: FileRoutesByTo to: + | '/' | '/login' | '/recover-password' | '/reset-password' | '/signup' + | '/about' + | '/races' + | '/admin/dashboard' + | '/admin/items' + | '/admin/settings' + | '/admin/users' | '/admin' - | '/items' - | '/settings' - | '/' id: | '__root__' | '/_layout' + | '/_public' | '/login' | '/recover-password' | '/reset-password' | '/signup' | '/_layout/admin' - | '/_layout/items' - | '/_layout/settings' - | '/_layout/' + | '/_public/about' + | '/_public/races' + | '/_public/' + | '/_layout/admin/dashboard' + | '/_layout/admin/items' + | '/_layout/admin/settings' + | '/_layout/admin/users' + | '/_layout/admin/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { LayoutRoute: typeof LayoutRouteWithChildren + PublicRoute: typeof PublicRouteWithChildren LoginRoute: typeof LoginRoute RecoverPasswordRoute: typeof RecoverPasswordRoute ResetPasswordRoute: typeof ResetPasswordRoute @@ -168,6 +234,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LoginRouteImport parentRoute: typeof rootRouteImport } + '/_public': { + id: '/_public' + path: '' + fullPath: '/' + preLoaderRoute: typeof PublicRouteImport + parentRoute: typeof rootRouteImport + } '/_layout': { id: '/_layout' path: '' @@ -175,26 +248,26 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutRouteImport parentRoute: typeof rootRouteImport } - '/_layout/': { - id: '/_layout/' + '/_public/': { + id: '/_public/' path: '/' fullPath: '/' - preLoaderRoute: typeof LayoutIndexRouteImport - parentRoute: typeof LayoutRoute + preLoaderRoute: typeof PublicIndexRouteImport + parentRoute: typeof PublicRoute } - '/_layout/settings': { - id: '/_layout/settings' - path: '/settings' - fullPath: '/settings' - preLoaderRoute: typeof LayoutSettingsRouteImport - parentRoute: typeof LayoutRoute + '/_public/races': { + id: '/_public/races' + path: '/races' + fullPath: '/races' + preLoaderRoute: typeof PublicRacesRouteImport + parentRoute: typeof PublicRoute } - '/_layout/items': { - id: '/_layout/items' - path: '/items' - fullPath: '/items' - preLoaderRoute: typeof LayoutItemsRouteImport - parentRoute: typeof LayoutRoute + '/_public/about': { + id: '/_public/about' + path: '/about' + fullPath: '/about' + preLoaderRoute: typeof PublicAboutRouteImport + parentRoute: typeof PublicRoute } '/_layout/admin': { id: '/_layout/admin' @@ -203,28 +276,93 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutAdminRouteImport parentRoute: typeof LayoutRoute } + '/_layout/admin/': { + id: '/_layout/admin/' + path: '/' + fullPath: '/admin/' + preLoaderRoute: typeof LayoutAdminIndexRouteImport + parentRoute: typeof LayoutAdminRoute + } + '/_layout/admin/users': { + id: '/_layout/admin/users' + path: '/users' + fullPath: '/admin/users' + preLoaderRoute: typeof LayoutAdminUsersRouteImport + parentRoute: typeof LayoutAdminRoute + } + '/_layout/admin/settings': { + id: '/_layout/admin/settings' + path: '/settings' + fullPath: '/admin/settings' + preLoaderRoute: typeof LayoutAdminSettingsRouteImport + parentRoute: typeof LayoutAdminRoute + } + '/_layout/admin/items': { + id: '/_layout/admin/items' + path: '/items' + fullPath: '/admin/items' + preLoaderRoute: typeof LayoutAdminItemsRouteImport + parentRoute: typeof LayoutAdminRoute + } + '/_layout/admin/dashboard': { + id: '/_layout/admin/dashboard' + path: '/dashboard' + fullPath: '/admin/dashboard' + preLoaderRoute: typeof LayoutAdminDashboardRouteImport + parentRoute: typeof LayoutAdminRoute + } } } +interface LayoutAdminRouteChildren { + LayoutAdminDashboardRoute: typeof LayoutAdminDashboardRoute + LayoutAdminItemsRoute: typeof LayoutAdminItemsRoute + LayoutAdminSettingsRoute: typeof LayoutAdminSettingsRoute + LayoutAdminUsersRoute: typeof LayoutAdminUsersRoute + LayoutAdminIndexRoute: typeof LayoutAdminIndexRoute +} + +const LayoutAdminRouteChildren: LayoutAdminRouteChildren = { + LayoutAdminDashboardRoute: LayoutAdminDashboardRoute, + LayoutAdminItemsRoute: LayoutAdminItemsRoute, + LayoutAdminSettingsRoute: LayoutAdminSettingsRoute, + LayoutAdminUsersRoute: LayoutAdminUsersRoute, + LayoutAdminIndexRoute: LayoutAdminIndexRoute, +} + +const LayoutAdminRouteWithChildren = LayoutAdminRoute._addFileChildren( + LayoutAdminRouteChildren, +) + interface LayoutRouteChildren { - LayoutAdminRoute: typeof LayoutAdminRoute - LayoutItemsRoute: typeof LayoutItemsRoute - LayoutSettingsRoute: typeof LayoutSettingsRoute - LayoutIndexRoute: typeof LayoutIndexRoute + LayoutAdminRoute: typeof LayoutAdminRouteWithChildren } const LayoutRouteChildren: LayoutRouteChildren = { - LayoutAdminRoute: LayoutAdminRoute, - LayoutItemsRoute: LayoutItemsRoute, - LayoutSettingsRoute: LayoutSettingsRoute, - LayoutIndexRoute: LayoutIndexRoute, + LayoutAdminRoute: LayoutAdminRouteWithChildren, } const LayoutRouteWithChildren = LayoutRoute._addFileChildren(LayoutRouteChildren) +interface PublicRouteChildren { + PublicAboutRoute: typeof PublicAboutRoute + PublicRacesRoute: typeof PublicRacesRoute + PublicIndexRoute: typeof PublicIndexRoute +} + +const PublicRouteChildren: PublicRouteChildren = { + PublicAboutRoute: PublicAboutRoute, + PublicRacesRoute: PublicRacesRoute, + PublicIndexRoute: PublicIndexRoute, +} + +const PublicRouteWithChildren = + PublicRoute._addFileChildren(PublicRouteChildren) + const rootRouteChildren: RootRouteChildren = { LayoutRoute: LayoutRouteWithChildren, + PublicRoute: PublicRouteWithChildren, LoginRoute: LoginRoute, RecoverPasswordRoute: RecoverPasswordRoute, ResetPasswordRoute: ResetPasswordRoute, diff --git a/frontend/src/routes/_layout.admin.tsx b/frontend/src/routes/_layout.admin.tsx new file mode 100644 index 0000000000..63f738b85d --- /dev/null +++ b/frontend/src/routes/_layout.admin.tsx @@ -0,0 +1,28 @@ +import { createFileRoute, Outlet, redirect } from "@tanstack/react-router" +import { UsersService } from "@/client" + +export const Route = createFileRoute("/_layout/admin")({ + component: AdminLayout, + beforeLoad: async () => { + const user = await UsersService.readUserMe() + if (!user.is_superuser) { + throw redirect({ + to: "/", + }) + } + }, +}) + +function AdminLayout() { + return ( +
+
+

Admin Panel

+

+ Manage users, items, and system settings +

+
+ +
+ ) +} diff --git a/frontend/src/routes/_layout/index.tsx b/frontend/src/routes/_layout.admin/dashboard.tsx similarity index 74% rename from frontend/src/routes/_layout/index.tsx rename to frontend/src/routes/_layout.admin/dashboard.tsx index 3e640cbbb8..fe28f32b5e 100644 --- a/frontend/src/routes/_layout/index.tsx +++ b/frontend/src/routes/_layout.admin/dashboard.tsx @@ -2,12 +2,12 @@ import { createFileRoute } from "@tanstack/react-router" import useAuth from "@/hooks/useAuth" -export const Route = createFileRoute("/_layout/")({ +export const Route = createFileRoute("/_layout/admin/dashboard")({ component: Dashboard, head: () => ({ meta: [ { - title: "Dashboard - FastAPI Template", + title: "Dashboard - Admin", }, ], }), @@ -19,9 +19,9 @@ function Dashboard() { return (
-

+

Hi, {currentUser?.full_name || currentUser?.email} 👋 -

+

Welcome back, nice to see you again!!!

diff --git a/frontend/src/routes/_layout.admin/index.tsx b/frontend/src/routes/_layout.admin/index.tsx new file mode 100644 index 0000000000..fca51689dd --- /dev/null +++ b/frontend/src/routes/_layout.admin/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute, redirect } from "@tanstack/react-router" + +export const Route = createFileRoute("/_layout/admin/")({ + beforeLoad: () => { + throw redirect({ + to: "/admin/dashboard", + }) + }, +}) diff --git a/frontend/src/routes/_layout/items.tsx b/frontend/src/routes/_layout.admin/items.tsx similarity index 80% rename from frontend/src/routes/_layout/items.tsx rename to frontend/src/routes/_layout.admin/items.tsx index a4df200023..3fdc4b0679 100644 --- a/frontend/src/routes/_layout/items.tsx +++ b/frontend/src/routes/_layout.admin/items.tsx @@ -16,12 +16,12 @@ function getItemsQueryOptions() { } } -export const Route = createFileRoute("/_layout/items")({ - component: Items, +export const Route = createFileRoute("/_layout/admin/items")({ + component: AdminItems, head: () => ({ meta: [ { - title: "Items - FastAPI Template", + title: "Item Management - Admin", }, ], }), @@ -36,7 +36,7 @@ function ItemsTableContent() {
-

You don't have any items yet

+

No items yet

Add a new item to get started

) @@ -53,13 +53,13 @@ function ItemsTable() { ) } -function Items() { +function AdminItems() { return (
-

Items

-

Create and manage your items

+

Items

+

Manage system items

diff --git a/frontend/src/routes/_layout/settings.tsx b/frontend/src/routes/_layout.admin/settings.tsx similarity index 89% rename from frontend/src/routes/_layout/settings.tsx rename to frontend/src/routes/_layout.admin/settings.tsx index e109b5ae81..7a356daefc 100644 --- a/frontend/src/routes/_layout/settings.tsx +++ b/frontend/src/routes/_layout.admin/settings.tsx @@ -12,12 +12,12 @@ const tabsConfig = [ { value: "danger-zone", title: "Danger zone", component: DeleteAccount }, ] -export const Route = createFileRoute("/_layout/settings")({ +export const Route = createFileRoute("/_layout/admin/settings")({ component: UserSettings, head: () => ({ meta: [ { - title: "Settings - FastAPI Template", + title: "Settings - Admin", }, ], }), @@ -36,7 +36,7 @@ function UserSettings() { return (
-

User Settings

+

User Settings

Manage your account settings and preferences

diff --git a/frontend/src/routes/_layout/admin.tsx b/frontend/src/routes/_layout.admin/users.tsx similarity index 76% rename from frontend/src/routes/_layout/admin.tsx rename to frontend/src/routes/_layout.admin/users.tsx index a53ff2c4e9..87daa53dcf 100644 --- a/frontend/src/routes/_layout/admin.tsx +++ b/frontend/src/routes/_layout.admin/users.tsx @@ -1,5 +1,5 @@ import { useSuspenseQuery } from "@tanstack/react-query" -import { createFileRoute, redirect } from "@tanstack/react-router" +import { createFileRoute } from "@tanstack/react-router" import { Suspense } from "react" import { type UserPublic, UsersService } from "@/client" @@ -16,20 +16,12 @@ function getUsersQueryOptions() { } } -export const Route = createFileRoute("/_layout/admin")({ - component: Admin, - beforeLoad: async () => { - const user = await UsersService.readUserMe() - if (!user.is_superuser) { - throw redirect({ - to: "/", - }) - } - }, +export const Route = createFileRoute("/_layout/admin/users")({ + component: AdminUsers, head: () => ({ meta: [ { - title: "Admin - FastAPI Template", + title: "User Management - Admin", }, ], }), @@ -55,12 +47,12 @@ function UsersTable() { ) } -function Admin() { +function AdminUsers() { return (
-

Users

+

Users

Manage user accounts and permissions

diff --git a/frontend/src/routes/_public.tsx b/frontend/src/routes/_public.tsx new file mode 100644 index 0000000000..e4b1177fe4 --- /dev/null +++ b/frontend/src/routes/_public.tsx @@ -0,0 +1,21 @@ +import { createFileRoute, Outlet } from "@tanstack/react-router" +import { PublicHeader } from "@/components/Public/PublicHeader" +import { PublicFooter } from "@/components/Public/PublicFooter" + +export const Route = createFileRoute("/_public")({ + component: PublicLayout, +}) + +function PublicLayout() { + return ( +
+ +
+ +
+ +
+ ) +} + +export default PublicLayout diff --git a/frontend/src/routes/_public/about.tsx b/frontend/src/routes/_public/about.tsx new file mode 100644 index 0000000000..a08bf9e38c --- /dev/null +++ b/frontend/src/routes/_public/about.tsx @@ -0,0 +1,114 @@ +import { createFileRoute } from "@tanstack/react-router" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" + +export const Route = createFileRoute("/_public/about")({ + component: AboutPage, + head: () => ({ + meta: [ + { + title: "About Us - RaceHub", + description: "Learn more about RaceHub and our mission to connect runners with races.", + }, + ], + }), +}) + +function AboutPage() { + return ( +
+
+
+ {/* Header */} +
+

About RaceHub

+

+ RaceHub is your go-to platform for discovering and registering for running races. + We connect runners with race organizers to create memorable racing experiences. +

+
+ + {/* Mission Card */} + + + Our Mission + + Making race discovery and registration simple for everyone + + + +

+ We believe running brings people together. Our platform makes it easy to find races + that match your goals, register securely online, and track your running journey. + Whether you're training for your first 5K or your tenth marathon, RaceHub helps you + find the perfect event. +

+
+
+ + {/* Features Grid */} +
+ + + For Runners + + +
    +
  • + + Browse races by distance, location, and date +
  • +
  • + + Register online with secure payment processing +
  • +
  • + + Track your race history and personal records +
  • +
  • + + Receive race updates and important information +
  • +
+
+
+ + + + For Race Organizers + + +
    +
  • + + Create and manage races with our intuitive dashboard +
  • +
  • + + Process registrations and participant management +
  • +
  • + + Access real-time registration data and reports +
  • +
  • + + Communicate with participants before and after the race +
  • +
+
+
+
+
+
+
+ ) +} + +export default AboutPage diff --git a/frontend/src/routes/_public/index.tsx b/frontend/src/routes/_public/index.tsx new file mode 100644 index 0000000000..3a43332a22 --- /dev/null +++ b/frontend/src/routes/_public/index.tsx @@ -0,0 +1,125 @@ +import { createFileRoute } from "@tanstack/react-router" +import { ArrowRight, Calendar, MapPin, Trophy } from "lucide-react" +import { Link } from "@tanstack/react-router" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" + +export const Route = createFileRoute("/_public/")({ + component: HomePage, + head: () => ({ + meta: [ + { + title: "RaceHub - Find and Register for Running Races", + description: + "Discover running races near you. Register online, track your progress, and join a community of runners.", + }, + ], + }), +}) + +const features = [ + { + icon: Calendar, + title: "Discover Races", + description: "Browse upcoming races in your area and find the perfect event for your goals.", + }, + { + icon: MapPin, + title: "Easy Registration", + description: "Register online in minutes with our simple and secure registration process.", + }, + { + icon: Trophy, + title: "Track Progress", + description: "View your race history, track your PRs, and celebrate your achievements.", + }, +] + +function HomePage() { + return ( + <> + {/* Hero Section */} +
+
+
+

+ Find Your Next Race +

+

+ Discover and register for running races near you. Join thousands of runners + achieving their goals. +

+
+ + +
+
+
+
+ + {/* Features Section */} +
+
+
+

+ Why Choose RaceHub? +

+

+ Everything you need to find, register, and prepare for your next running event. +

+
+
+ {features.map(({ icon: Icon, title, description }) => ( + + +
+ +
+ {title} + {description} +
+
+ ))} +
+
+
+ + {/* CTA Section */} +
+
+
+ + +
+

+ Ready to Start Running? +

+

+ Create your free account today and get access to hundreds of races in your area. +

+ +
+
+
+
+
+
+ + ) +} + +export default HomePage diff --git a/frontend/src/routes/_public/races.tsx b/frontend/src/routes/_public/races.tsx new file mode 100644 index 0000000000..0c68a2661c --- /dev/null +++ b/frontend/src/routes/_public/races.tsx @@ -0,0 +1,124 @@ +import { createFileRoute, Link } from "@tanstack/react-router" +import { Calendar, MapPin, Users } from "lucide-react" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" + +export const Route = createFileRoute("/_public/races")({ + component: RacesPage, + head: () => ({ + meta: [ + { + title: "Browse Races - RaceHub", + description: "Find and register for upcoming running races near you. Filter by distance, date, and location.", + }, + ], + }), +}) + +// Placeholder data - will be replaced with API data +const upcomingRaces = [ + { + id: "1", + name: "Spring Marathon 2026", + date: "April 15, 2026", + location: "Central Park, New York", + distance: "Marathon", + participants: 2500, + description: "Join us for the annual Spring Marathon through beautiful Central Park.", + }, + { + id: "2", + name: "City 10K Challenge", + date: "May 8, 2026", + location: "Downtown, San Francisco", + distance: "10K", + participants: 1200, + description: "A fast and flat 10K course through the heart of the city.", + }, + { + id: "3", + name: "Trail Half Marathon", + date: "June 20, 2026", + location: "Mountain View Trail, Colorado", + distance: "Half Marathon", + participants: 800, + description: "Experience stunning mountain views on this challenging trail race.", + }, +] + +function RacesPage() { + return ( +
+
+
+ {/* Header */} +
+

Upcoming Races

+

+ Browse and register for upcoming races. Find the perfect event that matches your goals + and fitness level. +

+
+ + {/* Filters - Placeholder */} +
+

+ Filters coming soon: Distance, Date, Location +

+
+ + {/* Races Grid */} +
+ {upcomingRaces.map((race) => ( + + +
+ {race.distance} +
+ {race.name} + {race.description} +
+ +
+ + {race.date} +
+
+ + {race.location} +
+
+ + {race.participants} registered +
+
+ + + +
+ ))} +
+ + {/* Empty State */} + {upcomingRaces.length === 0 && ( +
+

No races found. Check back soon!

+
+ )} +
+
+
+ ) +} + +export default RacesPage diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx index a1f83d7e5a..c88f25b37a 100644 --- a/frontend/src/routes/login.tsx +++ b/frontend/src/routes/login.tsx @@ -37,7 +37,7 @@ export const Route = createFileRoute("/login")({ beforeLoad: async () => { if (isLoggedIn()) { throw redirect({ - to: "/", + to: "/admin/dashboard", }) } }, From ebb4d9be0bc4b90ee37a6825c106d64e64c5f596 Mon Sep 17 00:00:00 2001 From: tantranvn Date: Sun, 29 Mar 2026 00:47:35 +0700 Subject: [PATCH 03/15] feat: add race management features including create, edit, and delete functionalities - Implemented DeleteRace component for race deletion with confirmation dialog. - Created EditRace component for editing race details with form validation. - Added RaceActionsMenu for managing race actions (edit, delete). - Defined columns for race data table including status and price formatting. - Developed UI components for alert dialogs and text areas. - Integrated media API for handling race media assets. - Established routes for race management including listing, editing, and adding new races. --- .gitignore | 3 +- ...15962f3dcaee_add_race_management_schema.py | 162 + .../2a7b0f12d4ef_add_media_assets_table.py | 53 + backend/app/api/main.py | 23 +- backend/app/api/routes/media.py | 242 ++ backend/app/api/routes/race_attributes.py | 126 + backend/app/api/routes/race_categories.py | 178 ++ backend/app/api/routes/race_registrations.py | 294 ++ backend/app/api/routes/race_results.py | 178 ++ backend/app/api/routes/races.py | 153 + backend/app/core/config.py | 4 + backend/app/crud.py | 696 ++++- backend/app/models.py | 644 +++- backend/app/services/media_storage.py | 57 + bun.lock | 172 ++ frontend/package.json | 2 + frontend/src/client/schemas.gen.ts | 2658 +++++++++++++++++ frontend/src/client/sdk.gen.ts | 659 +++- frontend/src/client/types.gen.ts | 555 ++++ .../components/Media/MediaGalleryManager.tsx | 490 +++ .../src/components/Public/PublicFooter.tsx | 3 +- .../src/components/Public/PublicHeader.tsx | 23 +- frontend/src/components/Races/AddRace.tsx | 474 +++ frontend/src/components/Races/DeleteRace.tsx | 67 + frontend/src/components/Races/EditRace.tsx | 341 +++ .../src/components/Races/RaceActionsMenu.tsx | 50 + frontend/src/components/Races/columns.tsx | 105 + .../src/components/Sidebar/AppSidebar.tsx | 3 +- frontend/src/components/Sidebar/User.tsx | 2 +- frontend/src/components/ui/alert-dialog.tsx | 194 ++ frontend/src/components/ui/textarea.tsx | 18 + frontend/src/index.css | 23 +- frontend/src/lib/media-api.ts | 190 ++ frontend/src/routeTree.gen.ts | 92 + .../_layout.admin/races.$raceId.edit.tsx | 40 + .../src/routes/_layout.admin/races.index.tsx | 69 + .../src/routes/_layout.admin/races.new.tsx | 18 + frontend/src/routes/_layout.admin/races.tsx | 16 + frontend/src/routes/_public.tsx | 2 +- frontend/src/routes/_public/about.tsx | 33 +- frontend/src/routes/_public/index.tsx | 31 +- frontend/src/routes/_public/races.tsx | 28 +- 42 files changed, 9116 insertions(+), 55 deletions(-) create mode 100644 backend/app/alembic/versions/15962f3dcaee_add_race_management_schema.py create mode 100644 backend/app/alembic/versions/2a7b0f12d4ef_add_media_assets_table.py create mode 100644 backend/app/api/routes/media.py create mode 100644 backend/app/api/routes/race_attributes.py create mode 100644 backend/app/api/routes/race_categories.py create mode 100644 backend/app/api/routes/race_registrations.py create mode 100644 backend/app/api/routes/race_results.py create mode 100644 backend/app/api/routes/races.py create mode 100644 backend/app/services/media_storage.py create mode 100644 frontend/src/components/Media/MediaGalleryManager.tsx create mode 100644 frontend/src/components/Races/AddRace.tsx create mode 100644 frontend/src/components/Races/DeleteRace.tsx create mode 100644 frontend/src/components/Races/EditRace.tsx create mode 100644 frontend/src/components/Races/RaceActionsMenu.tsx create mode 100644 frontend/src/components/Races/columns.tsx create mode 100644 frontend/src/components/ui/alert-dialog.tsx create mode 100644 frontend/src/components/ui/textarea.tsx create mode 100644 frontend/src/lib/media-api.ts create mode 100644 frontend/src/routes/_layout.admin/races.$raceId.edit.tsx create mode 100644 frontend/src/routes/_layout.admin/races.index.tsx create mode 100644 frontend/src/routes/_layout.admin/races.new.tsx create mode 100644 frontend/src/routes/_layout.admin/races.tsx diff --git a/.gitignore b/.gitignore index 5a5b06cbdf..bcfdcf2372 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ node_modules/ /playwright-report/ /blob-report/ /playwright/.cache/ -frontend/.tanstack/ \ No newline at end of file +frontend/.tanstack/ +backend/uploads/media/ \ No newline at end of file diff --git a/backend/app/alembic/versions/15962f3dcaee_add_race_management_schema.py b/backend/app/alembic/versions/15962f3dcaee_add_race_management_schema.py new file mode 100644 index 0000000000..9a39282786 --- /dev/null +++ b/backend/app/alembic/versions/15962f3dcaee_add_race_management_schema.py @@ -0,0 +1,162 @@ +"""Add race management schema + +Revision ID: 15962f3dcaee +Revises: 5280d245c748 +Create Date: 2026-03-28 23:41:03.309759 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = '15962f3dcaee' +down_revision = '5280d245c748' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('race', + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=True), + sa.Column('event_start_date', sa.DateTime(timezone=True), nullable=True), + sa.Column('event_end_date', sa.DateTime(timezone=True), nullable=True), + sa.Column('location', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('city', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=True), + sa.Column('state', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=True), + sa.Column('country', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False), + sa.Column('registration_start', sa.DateTime(timezone=True), nullable=True), + sa.Column('registration_end', sa.DateTime(timezone=True), nullable=True), + sa.Column('status', sa.Enum('DRAFT', 'PUBLISHED', 'REGISTRATION_OPEN', 'REGISTRATION_CLOSED', 'COMPLETED', 'CANCELLED', name='racestatusenum'), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('base_price', sa.Float(), nullable=True), + sa.Column('currency', sqlmodel.sql.sqltypes.AutoString(length=3), nullable=False), + sa.Column('race_metadata', sa.JSON(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('organizer_id', sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(['organizer_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_race_name'), 'race', ['name'], unique=False) + op.create_table('raceattribute', + sa.Column('key', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False), + sa.Column('value_text', sa.Text(), nullable=True), + sa.Column('attribute_type', sa.Enum('STRING', 'TEXT', 'URL', 'DATE', 'DATETIME', 'NUMBER', 'BOOLEAN', 'EMAIL', 'PHONE', name='attributetypeenum'), nullable=False), + sa.Column('label', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + sa.Column('is_required', sa.Boolean(), nullable=False), + sa.Column('is_public', sa.Boolean(), nullable=False), + sa.Column('display_order', sa.Integer(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('race_id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['race_id'], ['race.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_raceattribute_key'), 'raceattribute', ['key'], unique=False) + op.create_table('racecategory', + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False), + sa.Column('distance_km', sa.Float(), nullable=False), + sa.Column('distance_unit', sqlmodel.sql.sqltypes.AutoString(length=10), nullable=False), + sa.Column('start_time', sa.DateTime(timezone=True), nullable=True), + sa.Column('end_time', sa.DateTime(timezone=True), nullable=True), + sa.Column('cutoff_time_minutes', sa.Integer(), nullable=True), + sa.Column('registration_start', sa.DateTime(timezone=True), nullable=True), + sa.Column('registration_end', sa.DateTime(timezone=True), nullable=True), + sa.Column('price', sa.Float(), nullable=True), + sa.Column('early_bird_price', sa.Float(), nullable=True), + sa.Column('early_bird_deadline', sa.DateTime(timezone=True), nullable=True), + sa.Column('max_participants', sa.Integer(), nullable=True), + sa.Column('min_age', sa.Integer(), nullable=True), + sa.Column('max_age', sa.Integer(), nullable=True), + sa.Column('gender_restriction', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=True), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + sa.Column('display_order', sa.Integer(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('race_id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['race_id'], ['race.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('racecheckpoint', + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False), + sa.Column('distance_km', sa.Float(), nullable=False), + sa.Column('sequence', sa.Integer(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('race_id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['race_id'], ['race.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('raceregistration', + sa.Column('bib_number', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=True), + sa.Column('emergency_contact', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('emergency_phone', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=True), + sa.Column('tshirt_size', sqlmodel.sql.sqltypes.AutoString(length=10), nullable=True), + sa.Column('special_requirements', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + sa.Column('registration_status', sa.Enum('PENDING', 'CONFIRMED', 'CANCELLED', 'WAITLIST', name='registrationstatusenum'), nullable=False), + sa.Column('payment_status', sa.Enum('UNPAID', 'PAID', 'REFUNDED', 'PARTIAL', name='paymentstatusenum'), nullable=False), + sa.Column('amount_paid', sa.Float(), nullable=True), + sa.Column('payment_reference', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('registration_data', sa.JSON(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('race_id', sa.Uuid(), nullable=False), + sa.Column('category_id', sa.Uuid(), nullable=False), + sa.Column('runner_id', sa.Uuid(), nullable=False), + sa.Column('registered_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['category_id'], ['racecategory.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['race_id'], ['race.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['runner_id'], ['user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('raceresult', + sa.Column('finish_time_seconds', sa.Integer(), nullable=True), + sa.Column('overall_position', sa.Integer(), nullable=True), + sa.Column('category_position', sa.Integer(), nullable=True), + sa.Column('gender_position', sa.Integer(), nullable=True), + sa.Column('status', sa.Enum('FINISHED', 'DNF', 'DNS', 'DQ', name='resultstatusenum'), nullable=False), + sa.Column('pace_per_km_seconds', sa.Float(), nullable=True), + sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('registration_id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['registration_id'], ['raceregistration.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('registration_id') + ) + op.create_table('racesplittime', + sa.Column('time_seconds', sa.Integer(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('registration_id', sa.Uuid(), nullable=False), + sa.Column('checkpoint_id', sa.Uuid(), nullable=False), + sa.Column('recorded_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['checkpoint_id'], ['racecheckpoint.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['registration_id'], ['raceregistration.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('racesplittime') + op.drop_table('raceresult') + op.drop_table('raceregistration') + op.drop_table('racecheckpoint') + op.drop_table('racecategory') + op.drop_index(op.f('ix_raceattribute_key'), table_name='raceattribute') + op.drop_table('raceattribute') + op.drop_index(op.f('ix_race_name'), table_name='race') + op.drop_table('race') + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/2a7b0f12d4ef_add_media_assets_table.py b/backend/app/alembic/versions/2a7b0f12d4ef_add_media_assets_table.py new file mode 100644 index 0000000000..539d57edaf --- /dev/null +++ b/backend/app/alembic/versions/2a7b0f12d4ef_add_media_assets_table.py @@ -0,0 +1,53 @@ +"""Add media assets table + +Revision ID: 2a7b0f12d4ef +Revises: 15962f3dcaee +Create Date: 2026-03-29 11:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = "2a7b0f12d4ef" +down_revision = "15962f3dcaee" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "mediaasset", + sa.Column("content_type", sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False), + sa.Column("content_id", sa.Uuid(), nullable=False), + sa.Column("kind", sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False), + sa.Column("alt_text", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column("display_order", sa.Integer(), nullable=False), + sa.Column("is_primary", sa.Boolean(), nullable=False), + sa.Column("is_public", sa.Boolean(), nullable=False), + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("original_filename", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column("file_name", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column("file_path", sqlmodel.sql.sqltypes.AutoString(length=1000), nullable=False), + sa.Column("file_url", sqlmodel.sql.sqltypes.AutoString(length=1000), nullable=False), + sa.Column("mime_type", sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False), + sa.Column("size_bytes", sa.Integer(), nullable=False), + sa.Column("uploaded_by_id", sa.Uuid(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["uploaded_by_id"], ["user.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_mediaasset_content_type"), "mediaasset", ["content_type"], unique=False) + op.create_index(op.f("ix_mediaasset_content_id"), "mediaasset", ["content_id"], unique=False) + op.create_index(op.f("ix_mediaasset_kind"), "mediaasset", ["kind"], unique=False) + + +def downgrade(): + op.drop_index(op.f("ix_mediaasset_kind"), table_name="mediaasset") + op.drop_index(op.f("ix_mediaasset_content_id"), table_name="mediaasset") + op.drop_index(op.f("ix_mediaasset_content_type"), table_name="mediaasset") + op.drop_table("mediaasset") diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 2ae51a0ca9..3e7d42c920 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,6 +1,19 @@ from fastapi import APIRouter -from app.api.routes import items, login, private, roles, users, utils +from app.api.routes import ( + items, + login, + media, + private, + race_attributes, + race_categories, + race_registrations, + race_results, + races, + roles, + users, + utils, +) from app.core.config import settings api_router = APIRouter() @@ -9,6 +22,14 @@ api_router.include_router(roles.router) api_router.include_router(utils.router) api_router.include_router(items.router) +api_router.include_router(media.router) + +# Race management routes +api_router.include_router(races.router) +api_router.include_router(race_categories.router) +api_router.include_router(race_registrations.router) +api_router.include_router(race_results.router) +api_router.include_router(race_attributes.router) if settings.ENVIRONMENT == "local": diff --git a/backend/app/api/routes/media.py b/backend/app/api/routes/media.py new file mode 100644 index 0000000000..403c2695a9 --- /dev/null +++ b/backend/app/api/routes/media.py @@ -0,0 +1,242 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, File, Form, HTTPException, UploadFile +from fastapi.responses import FileResponse + +from app import crud +from app.api.deps import CurrentUser, SessionDep +from app.core.config import settings +from app.models import ( + MediaAssetCreate, + MediaAssetPublic, + MediaAssetsPublic, + MediaAssetUpdate, + Message, +) +from app.services.media_storage import ( + delete_media_file, + resolve_media_path, + save_uploaded_media, +) + +router = APIRouter(prefix="/media", tags=["media"]) + +ALLOWED_IMAGE_MIME_TYPES = { + "image/jpeg", + "image/png", + "image/webp", + "image/gif", + "image/avif", +} + + +def _can_manage_content( + *, + session: SessionDep, + current_user: CurrentUser, + content_type: str, + content_id: uuid.UUID, +) -> bool: + if current_user.is_superuser: + return True + + if content_type == "race": + race = crud.get_race(session=session, race_id=content_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + return race.organizer_id == current_user.id + + return False + + +@router.get("/", response_model=MediaAssetsPublic) +def read_media_assets( + session: SessionDep, + content_type: str | None = None, + content_id: uuid.UUID | None = None, + kind: str | None = None, + is_public: bool = True, + skip: int = 0, + limit: int = 200, +) -> Any: + """List media assets for any content type.""" + assets = crud.get_media_assets( + session=session, + content_type=content_type, + content_id=content_id, + kind=kind, + is_public=is_public, + skip=skip, + limit=limit, + ) + count = crud.get_media_assets_count( + session=session, + content_type=content_type, + content_id=content_id, + kind=kind, + is_public=is_public, + ) + assets_public = [MediaAssetPublic.model_validate(asset) for asset in assets] + return MediaAssetsPublic(data=assets_public, count=count) + + +@router.post("/upload", response_model=MediaAssetPublic) +def upload_media_asset( + *, + session: SessionDep, + current_user: CurrentUser, + file: UploadFile = File(...), + content_type: str = Form(...), + content_id: uuid.UUID = Form(...), + kind: str = Form("gallery"), + alt_text: str | None = Form(None), + display_order: int = Form(0), + is_primary: bool = Form(False), + is_public: bool = Form(True), +) -> Any: + """Upload media for any content type (currently race-aware for permissions).""" + normalized_content_type = content_type.strip().lower() + + can_manage = _can_manage_content( + session=session, + current_user=current_user, + content_type=normalized_content_type, + content_id=content_id, + ) + if not can_manage: + raise HTTPException(status_code=403, detail="Not enough permissions") + + if not file.content_type or file.content_type not in ALLOWED_IMAGE_MIME_TYPES: + raise HTTPException(status_code=400, detail="Only image uploads are allowed") + + stored_filename, relative_path, size_bytes = save_uploaded_media( + file=file, + content_type=normalized_content_type, + content_id=content_id, + ) + + max_size_bytes = settings.MEDIA_MAX_FILE_SIZE_MB * 1024 * 1024 + if size_bytes > max_size_bytes: + delete_media_file(relative_path) + raise HTTPException( + status_code=400, + detail=f"File exceeds max size of {settings.MEDIA_MAX_FILE_SIZE_MB}MB", + ) + + if is_primary: + crud.clear_primary_media( + session=session, + content_type=normalized_content_type, + content_id=content_id, + kind=kind, + ) + + media_in = MediaAssetCreate( + content_type=normalized_content_type, + content_id=content_id, + kind=kind, + alt_text=alt_text, + display_order=display_order, + is_primary=is_primary, + is_public=is_public, + original_filename=file.filename or stored_filename, + file_name=stored_filename, + file_path=relative_path, + file_url="", + mime_type=file.content_type, + size_bytes=size_bytes, + uploaded_by_id=current_user.id, + ) + + db_media = crud.create_media_asset(session=session, media_in=media_in) + db_media.file_url = f"{settings.API_V1_STR}/media/{db_media.id}/file" + session.add(db_media) + session.commit() + session.refresh(db_media) + + return db_media + + +@router.get("/{media_id}/file") +def read_media_file(session: SessionDep, media_id: uuid.UUID) -> Any: + """Serve a media file by media id.""" + media = crud.get_media_asset(session=session, media_id=media_id) + if not media: + raise HTTPException(status_code=404, detail="Media not found") + + file_path = resolve_media_path(media.file_path) + if not file_path.exists() or not file_path.is_file(): + raise HTTPException(status_code=404, detail="File not found") + + return FileResponse( + path=file_path, + media_type=media.mime_type, + filename=media.original_filename, + ) + + +@router.put("/{media_id}", response_model=MediaAssetPublic) +def update_media_asset( + *, + session: SessionDep, + current_user: CurrentUser, + media_id: uuid.UUID, + media_in: MediaAssetUpdate, +) -> Any: + """Update media metadata.""" + media = crud.get_media_asset(session=session, media_id=media_id) + if not media: + raise HTTPException(status_code=404, detail="Media not found") + + can_manage = _can_manage_content( + session=session, + current_user=current_user, + content_type=media.content_type, + content_id=media.content_id, + ) + if not can_manage: + raise HTTPException(status_code=403, detail="Not enough permissions") + + incoming = media_in.model_dump(exclude_unset=True) + next_kind = incoming.get("kind", media.kind) + next_is_primary = incoming.get("is_primary", media.is_primary) + + if next_is_primary: + crud.clear_primary_media( + session=session, + content_type=media.content_type, + content_id=media.content_id, + kind=next_kind, + exclude_id=media.id, + ) + + updated = crud.update_media_asset( + session=session, + db_media=media, + media_in=media_in, + ) + return updated + + +@router.delete("/{media_id}", response_model=Message) +def delete_media_asset( + *, session: SessionDep, current_user: CurrentUser, media_id: uuid.UUID +) -> Any: + """Delete a media asset and its file.""" + media = crud.get_media_asset(session=session, media_id=media_id) + if not media: + raise HTTPException(status_code=404, detail="Media not found") + + can_manage = _can_manage_content( + session=session, + current_user=current_user, + content_type=media.content_type, + content_id=media.content_id, + ) + if not can_manage: + raise HTTPException(status_code=403, detail="Not enough permissions") + + delete_media_file(media.file_path) + crud.delete_media_asset(session=session, media_id=media_id) + return Message(message="Media deleted successfully") diff --git a/backend/app/api/routes/race_attributes.py b/backend/app/api/routes/race_attributes.py new file mode 100644 index 0000000000..ec6cf77b6f --- /dev/null +++ b/backend/app/api/routes/race_attributes.py @@ -0,0 +1,126 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException + +from app import crud +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + Message, + RaceAttributeCreate, + RaceAttributePublic, + RaceAttributesPublic, + RaceAttributeUpdate, +) + +router = APIRouter(prefix="/race-attributes", tags=["race-attributes"]) + + +@router.get("/", response_model=RaceAttributesPublic) +def read_race_attributes( + session: SessionDep, + race_id: uuid.UUID, + is_public: bool | None = None, +) -> Any: + """ + Retrieve race attributes for a specific race. + Public endpoint - filters by is_public by default unless authenticated organizer/admin. + """ + attributes = crud.get_race_attributes( + session=session, race_id=race_id, is_public=is_public + ) + attributes_public = [ + RaceAttributePublic.model_validate(attr) for attr in attributes + ] + return RaceAttributesPublic(data=attributes_public, count=len(attributes_public)) + + +@router.get("/{attribute_id}", response_model=RaceAttributePublic) +def read_race_attribute(session: SessionDep, attribute_id: uuid.UUID) -> Any: + """ + Get race attribute by ID. + """ + attribute = crud.get_race_attribute(session=session, attribute_id=attribute_id) + if not attribute: + raise HTTPException(status_code=404, detail="Race attribute not found") + return attribute + + +@router.post("/", response_model=RaceAttributePublic) +def create_race_attribute( + *, + session: SessionDep, + current_user: CurrentUser, + attribute_in: RaceAttributeCreate, +) -> Any: + """ + Create new race attribute. + Only the race organizer or admin can create attributes. + """ + # Get the race to check permissions + race = crud.get_race(session=session, race_id=attribute_in.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Check permissions + if not current_user.is_superuser and race.organizer_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + attribute = crud.create_race_attribute(session=session, attribute_in=attribute_in) + return attribute + + +@router.put("/{attribute_id}", response_model=RaceAttributePublic) +def update_race_attribute( + *, + session: SessionDep, + current_user: CurrentUser, + attribute_id: uuid.UUID, + attribute_in: RaceAttributeUpdate, +) -> Any: + """ + Update a race attribute. + Only the race organizer or admin can update. + """ + attribute = crud.get_race_attribute(session=session, attribute_id=attribute_id) + if not attribute: + raise HTTPException(status_code=404, detail="Race attribute not found") + + # Get the race to check permissions + race = crud.get_race(session=session, race_id=attribute.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Check permissions + if not current_user.is_superuser and race.organizer_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + attribute = crud.update_race_attribute( + session=session, db_attribute=attribute, attribute_in=attribute_in + ) + return attribute + + +@router.delete("/{attribute_id}", response_model=Message) +def delete_race_attribute( + *, session: SessionDep, current_user: CurrentUser, attribute_id: uuid.UUID +) -> Any: + """ + Delete a race attribute. + Only the race organizer or admin can delete. + """ + attribute = crud.get_race_attribute(session=session, attribute_id=attribute_id) + if not attribute: + raise HTTPException(status_code=404, detail="Race attribute not found") + + # Get the race to check permissions + race = crud.get_race(session=session, race_id=attribute.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Check permissions + if not current_user.is_superuser and race.organizer_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + crud.delete_race_attribute(session=session, attribute_id=attribute_id) + return Message(message="Race attribute deleted successfully") diff --git a/backend/app/api/routes/race_categories.py b/backend/app/api/routes/race_categories.py new file mode 100644 index 0000000000..58d28f3987 --- /dev/null +++ b/backend/app/api/routes/race_categories.py @@ -0,0 +1,178 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException + +from app import crud +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + Message, + RaceCategoriesPublic, + RaceCategoryCreate, + RaceCategoryPublic, + RaceCategoryPublicWithDetails, + RaceCategoryUpdate, +) + +router = APIRouter(prefix="/race-categories", tags=["race-categories"]) + + +@router.get("/", response_model=RaceCategoriesPublic) +def read_race_categories( + session: SessionDep, + race_id: uuid.UUID, + skip: int = 0, + limit: int = 100, +) -> Any: + """ + Retrieve race categories for a specific race. Public endpoint. + """ + categories = crud.get_race_categories( + session=session, race_id=race_id, skip=skip, limit=limit + ) + + # Add computed fields for public response + enriched_categories = [] + race = crud.get_race(session=session, race_id=race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + for category in categories: + registration_count = crud.get_category_registration_count( + session=session, category_id=category.id + ) + available_spots = crud.get_category_available_spots( + session=session, + category_id=category.id, + max_participants=category.max_participants, + ) + is_registration_open = crud.is_category_registration_open( + category=category, race=race, session=session + ) + current_price = crud.get_category_current_price(category=category, race=race) + + enriched_category = RaceCategoryPublicWithDetails( + **category.model_dump(), + registration_count=registration_count, + available_spots=available_spots, + is_registration_open=is_registration_open, + current_price=current_price, + ) + enriched_categories.append(enriched_category) + + return RaceCategoriesPublic( + data=enriched_categories, count=len(enriched_categories) + ) + + +@router.get("/{category_id}", response_model=RaceCategoryPublicWithDetails) +def read_race_category(session: SessionDep, category_id: uuid.UUID) -> Any: + """ + Get race category by ID with details. Public endpoint. + """ + category = crud.get_race_category(session=session, category_id=category_id) + if not category: + raise HTTPException(status_code=404, detail="Race category not found") + + race = crud.get_race(session=session, race_id=category.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Add computed fields + registration_count = crud.get_category_registration_count( + session=session, category_id=category.id + ) + available_spots = crud.get_category_available_spots( + session=session, + category_id=category.id, + max_participants=category.max_participants, + ) + is_registration_open = crud.is_category_registration_open( + category=category, race=race, session=session + ) + current_price = crud.get_category_current_price(category=category, race=race) + + return RaceCategoryPublicWithDetails( + **category.model_dump(), + registration_count=registration_count, + available_spots=available_spots, + is_registration_open=is_registration_open, + current_price=current_price, + ) + + +@router.post("/", response_model=RaceCategoryPublic) +def create_race_category( + *, session: SessionDep, current_user: CurrentUser, category_in: RaceCategoryCreate +) -> Any: + """ + Create new race category. + Only the race organizer or admin can create categories. + """ + # Get the race to check permissions + race = crud.get_race(session=session, race_id=category_in.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Check permissions + if not current_user.is_superuser and race.organizer_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + category = crud.create_race_category(session=session, category_in=category_in) + return category + + +@router.put("/{category_id}", response_model=RaceCategoryPublic) +def update_race_category( + *, + session: SessionDep, + current_user: CurrentUser, + category_id: uuid.UUID, + category_in: RaceCategoryUpdate, +) -> Any: + """ + Update a race category. + Only the race organizer or admin can update. + """ + category = crud.get_race_category(session=session, category_id=category_id) + if not category: + raise HTTPException(status_code=404, detail="Race category not found") + + # Get the race to check permissions + race = crud.get_race(session=session, race_id=category.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Check permissions + if not current_user.is_superuser and race.organizer_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + category = crud.update_race_category( + session=session, db_category=category, category_in=category_in + ) + return category + + +@router.delete("/{category_id}", response_model=Message) +def delete_race_category( + *, session: SessionDep, current_user: CurrentUser, category_id: uuid.UUID +) -> Any: + """ + Delete a race category. + Only the race organizer or admin can delete. + """ + category = crud.get_race_category(session=session, category_id=category_id) + if not category: + raise HTTPException(status_code=404, detail="Race category not found") + + # Get the race to check permissions + race = crud.get_race(session=session, race_id=category.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Check permissions + if not current_user.is_superuser and race.organizer_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + crud.delete_race_category(session=session, category_id=category_id) + return Message(message="Race category deleted successfully") diff --git a/backend/app/api/routes/race_registrations.py b/backend/app/api/routes/race_registrations.py new file mode 100644 index 0000000000..00fe685b5d --- /dev/null +++ b/backend/app/api/routes/race_registrations.py @@ -0,0 +1,294 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException + +from app import crud +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + Message, + RaceCategoryPublic, + RaceRegistrationCreate, + RaceRegistrationPublic, + RaceRegistrationPublicWithDetails, + RaceRegistrationsPublic, + RaceRegistrationUpdate, + User, + UserPublic, +) + +router = APIRouter(prefix="/race-registrations", tags=["race-registrations"]) + + +@router.get("/", response_model=RaceRegistrationsPublic) +def read_race_registrations( + session: SessionDep, + current_user: CurrentUser, + race_id: uuid.UUID | None = None, + category_id: uuid.UUID | None = None, + skip: int = 0, + limit: int = 100, +) -> Any: + """ + Retrieve race registrations. + - Runners see only their own registrations + - Organizers see registrations for their races + - Admins see all registrations + """ + if current_user.is_superuser: + # Admin sees all + registrations = crud.get_race_registrations( + session=session, + race_id=race_id, + category_id=category_id, + skip=skip, + limit=limit, + ) + count = crud.get_race_registrations_count( + session=session, race_id=race_id, category_id=category_id + ) + elif crud.user_has_any_role(current_user, ["organizer"]): + # Organizer sees registrations for their races + if race_id: + race = crud.get_race(session=session, race_id=race_id) + if not race or race.organizer_id != current_user.id: + raise HTTPException( + status_code=403, + detail="You can only view registrations for your own races", + ) + registrations = crud.get_race_registrations( + session=session, + race_id=race_id, + category_id=category_id, + skip=skip, + limit=limit, + ) + count = crud.get_race_registrations_count( + session=session, race_id=race_id, category_id=category_id + ) + else: + # Runner sees only their own registrations + registrations = crud.get_race_registrations( + session=session, + race_id=race_id, + runner_id=current_user.id, + category_id=category_id, + skip=skip, + limit=limit, + ) + count = crud.get_race_registrations_count( + session=session, + race_id=race_id, + runner_id=current_user.id, + category_id=category_id, + ) + + registrations_public = [ + RaceRegistrationPublic.model_validate(reg) for reg in registrations + ] + return RaceRegistrationsPublic(data=registrations_public, count=count) + + +@router.get("/my", response_model=RaceRegistrationsPublic) +def read_my_registrations( + session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100 +) -> Any: + """ + Retrieve current user's race registrations. + """ + registrations = crud.get_race_registrations( + session=session, runner_id=current_user.id, skip=skip, limit=limit + ) + count = crud.get_race_registrations_count( + session=session, runner_id=current_user.id + ) + registrations_public = [ + RaceRegistrationPublic.model_validate(reg) for reg in registrations + ] + return RaceRegistrationsPublic(data=registrations_public, count=count) + + +@router.get("/{registration_id}", response_model=RaceRegistrationPublicWithDetails) +def read_race_registration( + session: SessionDep, current_user: CurrentUser, registration_id: uuid.UUID +) -> Any: + """ + Get race registration by ID with details. + """ + registration = crud.get_race_registration( + session=session, registration_id=registration_id + ) + if not registration: + raise HTTPException(status_code=404, detail="Registration not found") + + # Check permissions + race = crud.get_race(session=session, race_id=registration.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + if ( + not current_user.is_superuser + and registration.runner_id != current_user.id + and race.organizer_id != current_user.id + ): + raise HTTPException(status_code=403, detail="Not enough permissions") + + # Get related data + runner = session.get(User, registration.runner_id) + if not runner: + raise HTTPException(status_code=404, detail="Runner not found") + + category = crud.get_race_category( + session=session, category_id=registration.category_id + ) + if not category: + raise HTTPException(status_code=404, detail="Category not found") + + return RaceRegistrationPublicWithDetails( + **registration.model_dump(), + runner=UserPublic.model_validate(runner), + category=RaceCategoryPublic.model_validate(category), + ) + + +@router.post("/", response_model=RaceRegistrationPublic) +def create_race_registration( + *, + session: SessionDep, + current_user: CurrentUser, + registration_in: RaceRegistrationCreate, +) -> Any: + """ + Create new race registration. + Runners can register themselves for races. + """ + # Check if race and category exist + race = crud.get_race(session=session, race_id=registration_in.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + category = crud.get_race_category( + session=session, category_id=registration_in.category_id + ) + if not category: + raise HTTPException(status_code=404, detail="Race category not found") + + # Verify category belongs to race + if category.race_id != race.id: + raise HTTPException( + status_code=400, detail="Category does not belong to this race" + ) + + # Check if already registered + existing_registration = crud.check_existing_registration( + session=session, runner_id=current_user.id, race_id=race.id + ) + if existing_registration: + raise HTTPException( + status_code=400, detail="You are already registered for this race" + ) + + # Check if registration is open + if not crud.is_category_registration_open( + category=category, race=race, session=session + ): + raise HTTPException( + status_code=400, detail="Registration is not open for this category" + ) + + # Create registration + registration = crud.create_race_registration( + session=session, registration_in=registration_in, runner_id=current_user.id + ) + return registration + + +@router.put("/{registration_id}", response_model=RaceRegistrationPublic) +def update_race_registration( + *, + session: SessionDep, + current_user: CurrentUser, + registration_id: uuid.UUID, + registration_in: RaceRegistrationUpdate, +) -> Any: + """ + Update a race registration. + - Runners can update their own registration details + - Organizers can update any field for their races + - Admins can update anything + """ + registration = crud.get_race_registration( + session=session, registration_id=registration_id + ) + if not registration: + raise HTTPException(status_code=404, detail="Registration not found") + + # Get the race to check permissions + race = crud.get_race(session=session, race_id=registration.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Check permissions + is_own_registration = registration.runner_id == current_user.id + is_race_organizer = race.organizer_id == current_user.id + + if ( + not current_user.is_superuser + and not is_own_registration + and not is_race_organizer + ): + raise HTTPException(status_code=403, detail="Not enough permissions") + + # Runners can only update certain fields + if is_own_registration and not is_race_organizer and not current_user.is_superuser: + # Restrict what runners can update + restricted_fields = { + "emergency_contact", + "emergency_phone", + "tshirt_size", + "special_requirements", + } + update_data = registration_in.model_dump(exclude_unset=True) + if any(field not in restricted_fields for field in update_data.keys()): + raise HTTPException( + status_code=403, + detail="You can only update your personal information", + ) + + registration = crud.update_race_registration( + session=session, db_registration=registration, registration_in=registration_in + ) + return registration + + +@router.delete("/{registration_id}", response_model=Message) +def delete_race_registration( + *, session: SessionDep, current_user: CurrentUser, registration_id: uuid.UUID +) -> Any: + """ + Delete/Cancel a race registration. + - Runners can cancel their own registrations + - Organizers can cancel registrations for their races + - Admins can cancel any registration + """ + registration = crud.get_race_registration( + session=session, registration_id=registration_id + ) + if not registration: + raise HTTPException(status_code=404, detail="Registration not found") + + # Get the race to check permissions + race = crud.get_race(session=session, race_id=registration.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Check permissions + if ( + not current_user.is_superuser + and registration.runner_id != current_user.id + and race.organizer_id != current_user.id + ): + raise HTTPException(status_code=403, detail="Not enough permissions") + + crud.delete_race_registration(session=session, registration_id=registration_id) + return Message(message="Registration cancelled successfully") diff --git a/backend/app/api/routes/race_results.py b/backend/app/api/routes/race_results.py new file mode 100644 index 0000000000..fac560b9a3 --- /dev/null +++ b/backend/app/api/routes/race_results.py @@ -0,0 +1,178 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException + +from app import crud +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + Message, + RaceResultCreate, + RaceResultPublic, + RaceResultsPublic, + RaceResultUpdate, +) + +router = APIRouter(prefix="/race-results", tags=["race-results"]) + + +@router.get("/", response_model=RaceResultsPublic) +def read_race_results( + session: SessionDep, + race_id: uuid.UUID, + skip: int = 0, + limit: int = 100, +) -> Any: + """ + Retrieve race results for a specific race. Public endpoint. + """ + race = crud.get_race(session=session, race_id=race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + results = crud.get_race_results( + session=session, race_id=race_id, skip=skip, limit=limit + ) + results_public = [RaceResultPublic.model_validate(result) for result in results] + return RaceResultsPublic(data=results_public, count=len(results_public)) + + +@router.get("/{result_id}", response_model=RaceResultPublic) +def read_race_result(session: SessionDep, result_id: uuid.UUID) -> Any: + """ + Get race result by ID. Public endpoint. + """ + result = crud.get_race_result(session=session, result_id=result_id) + if not result: + raise HTTPException(status_code=404, detail="Race result not found") + return result + + +@router.get("/registration/{registration_id}", response_model=RaceResultPublic) +def read_race_result_by_registration( + session: SessionDep, registration_id: uuid.UUID +) -> Any: + """ + Get race result by registration ID. Public endpoint. + """ + result = crud.get_race_result_by_registration( + session=session, registration_id=registration_id + ) + if not result: + raise HTTPException(status_code=404, detail="Race result not found") + return result + + +@router.post("/", response_model=RaceResultPublic) +def create_race_result( + *, session: SessionDep, current_user: CurrentUser, result_in: RaceResultCreate +) -> Any: + """ + Create new race result. + Only race organizers, volunteers, or admins can create results. + """ + # Get registration to verify race + registration = crud.get_race_registration( + session=session, registration_id=result_in.registration_id + ) + if not registration: + raise HTTPException(status_code=404, detail="Registration not found") + + # Get the race to check permissions + race = crud.get_race(session=session, race_id=registration.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Check permissions - only organizer, volunteer, or admin + if not current_user.is_superuser and race.organizer_id != current_user.id: + if not crud.user_has_any_role(current_user, ["volunteer"]): + raise HTTPException( + status_code=403, + detail="Only race organizers, volunteers, or admins can create results", + ) + + # Check if result already exists + existing_result = crud.get_race_result_by_registration( + session=session, registration_id=result_in.registration_id + ) + if existing_result: + raise HTTPException( + status_code=400, detail="Result already exists for this registration" + ) + + result = crud.create_race_result(session=session, result_in=result_in) + return result + + +@router.put("/{result_id}", response_model=RaceResultPublic) +def update_race_result( + *, + session: SessionDep, + current_user: CurrentUser, + result_id: uuid.UUID, + result_in: RaceResultUpdate, +) -> Any: + """ + Update a race result. + Only race organizers, volunteers, or admins can update results. + """ + result = crud.get_race_result(session=session, result_id=result_id) + if not result: + raise HTTPException(status_code=404, detail="Race result not found") + + # Get registration to verify race + registration = crud.get_race_registration( + session=session, registration_id=result.registration_id + ) + if not registration: + raise HTTPException(status_code=404, detail="Registration not found") + + # Get the race to check permissions + race = crud.get_race(session=session, race_id=registration.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Check permissions - only organizer, volunteer, or admin + if not current_user.is_superuser and race.organizer_id != current_user.id: + if not crud.user_has_any_role(current_user, ["volunteer"]): + raise HTTPException( + status_code=403, + detail="Only race organizers, volunteers, or admins can update results", + ) + + result = crud.update_race_result( + session=session, db_result=result, result_in=result_in + ) + return result + + +@router.delete("/{result_id}", response_model=Message) +def delete_race_result( + *, session: SessionDep, current_user: CurrentUser, result_id: uuid.UUID +) -> Any: + """ + Delete a race result. + Only race organizers or admins can delete results. + """ + result = crud.get_race_result(session=session, result_id=result_id) + if not result: + raise HTTPException(status_code=404, detail="Race result not found") + + # Get registration to verify race + registration = crud.get_race_registration( + session=session, registration_id=result.registration_id + ) + if not registration: + raise HTTPException(status_code=404, detail="Registration not found") + + # Get the race to check permissions + race = crud.get_race(session=session, race_id=registration.race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Check permissions - only organizer or admin + if not current_user.is_superuser and race.organizer_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + crud.delete_race_result(session=session, result_id=result_id) + return Message(message="Race result deleted successfully") diff --git a/backend/app/api/routes/races.py b/backend/app/api/routes/races.py new file mode 100644 index 0000000000..65652b4245 --- /dev/null +++ b/backend/app/api/routes/races.py @@ -0,0 +1,153 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException + +from app import crud +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + Message, + RaceCategoryPublic, + RaceCreate, + RacePublic, + RacePublicWithDetails, + RacesPublic, + RaceUpdate, +) + +router = APIRouter(prefix="/races", tags=["races"]) + + +@router.get("/", response_model=RacesPublic) +def read_races( + session: SessionDep, + skip: int = 0, + limit: int = 100, + organizer_id: uuid.UUID | None = None, +) -> Any: + """ + Retrieve races. Public endpoint - anyone can view races. + Optionally filter by organizer_id. + """ + races = crud.get_races( + session=session, skip=skip, limit=limit, organizer_id=organizer_id + ) + count = crud.get_races_count(session=session, organizer_id=organizer_id) + races_public = [RacePublic.model_validate(race) for race in races] + return RacesPublic(data=races_public, count=count) + + +@router.get("/{race_id}", response_model=RacePublicWithDetails) +def read_race(session: SessionDep, race_id: uuid.UUID) -> Any: + """ + Get race by ID with details. Public endpoint. + """ + race = crud.get_race(session=session, race_id=race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Get categories + categories = crud.get_race_categories(session=session, race_id=race_id) + categories_public = [RaceCategoryPublic.model_validate(cat) for cat in categories] + + # Get registration count + registration_count = crud.get_race_registrations_count( + session=session, race_id=race_id + ) + + return RacePublicWithDetails( + **race.model_dump(), + categories=categories_public, + registration_count=registration_count, + ) + + +@router.get("/my/organized", response_model=RacesPublic) +def read_my_organized_races( + session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100 +) -> Any: + """ + Retrieve races organized by the current user. + Requires organizer or admin role. + """ + # Check if user has organizer or admin role + if not current_user.is_superuser and not crud.user_has_any_role( + current_user, ["organizer", "admin"] + ): + raise HTTPException( + status_code=403, + detail="Only organizers and admins can access this endpoint", + ) + + races = crud.get_races( + session=session, skip=skip, limit=limit, organizer_id=current_user.id + ) + count = crud.get_races_count(session=session, organizer_id=current_user.id) + races_public = [RacePublic.model_validate(race) for race in races] + return RacesPublic(data=races_public, count=count) + + +@router.post("/", response_model=RacePublic) +def create_race( + *, session: SessionDep, current_user: CurrentUser, race_in: RaceCreate +) -> Any: + """ + Create new race. + Requires organizer or admin role. + """ + # Check if user has organizer or admin role + if not current_user.is_superuser and not crud.user_has_any_role( + current_user, ["organizer", "admin"] + ): + raise HTTPException( + status_code=403, detail="Only organizers and admins can create races" + ) + + race = crud.create_race( + session=session, race_in=race_in, organizer_id=current_user.id + ) + return race + + +@router.put("/{race_id}", response_model=RacePublic) +def update_race( + *, + session: SessionDep, + current_user: CurrentUser, + race_id: uuid.UUID, + race_in: RaceUpdate, +) -> Any: + """ + Update a race. + Only the organizer or admin can update. + """ + race = crud.get_race(session=session, race_id=race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Check permissions + if not current_user.is_superuser and race.organizer_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + race = crud.update_race(session=session, db_race=race, race_in=race_in) + return race + + +@router.delete("/{race_id}", response_model=Message) +def delete_race( + *, session: SessionDep, current_user: CurrentUser, race_id: uuid.UUID +) -> Any: + """ + Delete a race. + Only the organizer or admin can delete. + """ + race = crud.get_race(session=session, race_id=race_id) + if not race: + raise HTTPException(status_code=404, detail="Race not found") + + # Check permissions + if not current_user.is_superuser and race.organizer_id != current_user.id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + crud.delete_race(session=session, race_id=race_id) + return Message(message="Race deleted successfully") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 650b9f7910..5c8d3e8bed 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -94,6 +94,10 @@ def emails_enabled(self) -> bool: FIRST_SUPERUSER: EmailStr FIRST_SUPERUSER_PASSWORD: str + # Media uploads + MEDIA_UPLOAD_DIR: str = "uploads/media" + MEDIA_MAX_FILE_SIZE_MB: int = 15 + def _check_default_secret(self, var_name: str, value: str | None) -> None: if value == "changethis": message = ( diff --git a/backend/app/crud.py b/backend/app/crud.py index 9f669314cf..3ae2cb09a0 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -1,10 +1,42 @@ import uuid +from datetime import datetime, timezone from typing import Any -from sqlmodel import Session, select +from sqlmodel import Session, col, func, select from app.core.security import get_password_hash, verify_password -from app.models import Item, ItemCreate, Role, RoleCreate, User, UserCreate, UserUpdate +from app.models import ( + Item, + ItemCreate, + MediaAsset, + MediaAssetCreate, + MediaAssetUpdate, + Race, + RaceAttribute, + RaceAttributeCreate, + RaceAttributeUpdate, + RaceCategory, + RaceCategoryCreate, + RaceCategoryUpdate, + RaceCheckpoint, + RaceCheckpointCreate, + RaceCheckpointUpdate, + RaceCreate, + RaceRegistration, + RaceRegistrationCreate, + RaceRegistrationUpdate, + RaceResult, + RaceResultCreate, + RaceResultUpdate, + RaceSplitTime, + RaceSplitTimeCreate, + RaceUpdate, + Role, + RoleCreate, + User, + UserCreate, + UserUpdate, +) # Role CRUD operations @@ -21,13 +53,15 @@ def get_role_by_name(*, session: Session, name: str) -> Role | None: return session.exec(statement).first() -def get_or_create_role(*, session: Session, role_name: str, description: str | None = None) -> Role: +def get_or_create_role( + *, session: Session, role_name: str, description: str | None = None +) -> Role: """Get existing role or create it if it doesn't exist.""" role = get_role_by_name(session=session, name=role_name) if not role: role = create_role( session=session, - role_create=RoleCreate(name=role_name, description=description) + role_create=RoleCreate(name=role_name, description=description), ) return role @@ -65,7 +99,9 @@ def user_has_any_role(user: User, role_names: list[str]) -> bool: # User CRUD operations -def create_user(*, session: Session, user_create: UserCreate, default_role: str | None = "runner") -> User: +def create_user( + *, session: Session, user_create: UserCreate, default_role: str | None = "runner" +) -> User: db_obj = User.model_validate( user_create, update={"hashed_password": get_password_hash(user_create.password)} ) @@ -131,3 +167,653 @@ def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) - session.commit() session.refresh(db_item) return db_item + + +# ============================================================================= +# Race CRUD operations +# ============================================================================= + + +def create_race( + *, session: Session, race_in: RaceCreate, organizer_id: uuid.UUID +) -> Race: + """Create a new race.""" + db_race = Race.model_validate(race_in, update={"organizer_id": organizer_id}) + session.add(db_race) + session.commit() + session.refresh(db_race) + return db_race + + +def get_race(*, session: Session, race_id: uuid.UUID) -> Race | None: + """Get a race by ID.""" + return session.get(Race, race_id) + + +def get_races( + *, + session: Session, + skip: int = 0, + limit: int = 100, + organizer_id: uuid.UUID | None = None, +) -> list[Race]: + """Get races with pagination, optionally filtered by organizer.""" + statement = select(Race).offset(skip).limit(limit) + if organizer_id: + statement = statement.where(Race.organizer_id == organizer_id) + statement = statement.order_by(col(Race.event_start_date).desc()) + return list(session.exec(statement).all()) + + +def get_races_count(*, session: Session, organizer_id: uuid.UUID | None = None) -> int: + """Get total count of races.""" + statement = select(func.count(Race.id)) + if organizer_id: + statement = statement.where(Race.organizer_id == organizer_id) + return session.exec(statement).one() + + +def update_race(*, session: Session, db_race: Race, race_in: RaceUpdate) -> Race: + """Update a race.""" + race_data = race_in.model_dump(exclude_unset=True) + race_data["updated_at"] = datetime.now(timezone.utc) + db_race.sqlmodel_update(race_data) + session.add(db_race) + session.commit() + session.refresh(db_race) + return db_race + + +def delete_race(*, session: Session, race_id: uuid.UUID) -> bool: + """Delete a race.""" + race = session.get(Race, race_id) + if race: + session.delete(race) + session.commit() + return True + return False + + +# ============================================================================= +# MediaAsset CRUD operations +# ============================================================================= + + +def create_media_asset(*, session: Session, media_in: MediaAssetCreate) -> MediaAsset: + """Create a media asset.""" + db_media = MediaAsset.model_validate(media_in) + session.add(db_media) + session.commit() + session.refresh(db_media) + return db_media + + +def get_media_asset(*, session: Session, media_id: uuid.UUID) -> MediaAsset | None: + """Get a media asset by ID.""" + return session.get(MediaAsset, media_id) + + +def get_media_assets( + *, + session: Session, + content_type: str | None = None, + content_id: uuid.UUID | None = None, + kind: str | None = None, + is_public: bool | None = None, + skip: int = 0, + limit: int = 200, +) -> list[MediaAsset]: + """Get media assets with optional filtering.""" + statement = select(MediaAsset) + if content_type: + statement = statement.where(MediaAsset.content_type == content_type) + if content_id: + statement = statement.where(MediaAsset.content_id == content_id) + if kind: + statement = statement.where(MediaAsset.kind == kind) + if is_public is not None: + statement = statement.where(MediaAsset.is_public == is_public) + + statement = ( + statement.order_by( + col(MediaAsset.display_order), + col(MediaAsset.created_at).desc(), + ) + .offset(skip) + .limit(limit) + ) + return list(session.exec(statement).all()) + + +def get_media_assets_count( + *, + session: Session, + content_type: str | None = None, + content_id: uuid.UUID | None = None, + kind: str | None = None, + is_public: bool | None = None, +) -> int: + """Get count of media assets with optional filtering.""" + statement = select(func.count(MediaAsset.id)) + if content_type: + statement = statement.where(MediaAsset.content_type == content_type) + if content_id: + statement = statement.where(MediaAsset.content_id == content_id) + if kind: + statement = statement.where(MediaAsset.kind == kind) + if is_public is not None: + statement = statement.where(MediaAsset.is_public == is_public) + return session.exec(statement).one() + + +def clear_primary_media( + *, + session: Session, + content_type: str, + content_id: uuid.UUID, + kind: str, + exclude_id: uuid.UUID | None = None, +) -> None: + """Ensure only one media item is primary for a given content_type/content_id/kind.""" + statement = select(MediaAsset).where( + MediaAsset.content_type == content_type, + MediaAsset.content_id == content_id, + MediaAsset.kind == kind, + MediaAsset.is_primary, + ) + if exclude_id: + statement = statement.where(MediaAsset.id != exclude_id) + + existing_primary = list(session.exec(statement).all()) + for media in existing_primary: + media.is_primary = False + media.updated_at = datetime.now(timezone.utc) + session.add(media) + if existing_primary: + session.commit() + + +def update_media_asset( + *, session: Session, db_media: MediaAsset, media_in: MediaAssetUpdate +) -> MediaAsset: + """Update a media asset.""" + media_data = media_in.model_dump(exclude_unset=True) + media_data["updated_at"] = datetime.now(timezone.utc) + db_media.sqlmodel_update(media_data) + session.add(db_media) + session.commit() + session.refresh(db_media) + return db_media + + +def delete_media_asset(*, session: Session, media_id: uuid.UUID) -> bool: + """Delete a media asset.""" + media = session.get(MediaAsset, media_id) + if media: + session.delete(media) + session.commit() + return True + return False + + +# ============================================================================= +# RaceCategory CRUD operations +# ============================================================================= + + +def create_race_category( + *, session: Session, category_in: RaceCategoryCreate +) -> RaceCategory: + """Create a new race category.""" + db_category = RaceCategory.model_validate(category_in) + session.add(db_category) + session.commit() + session.refresh(db_category) + return db_category + + +def get_race_category( + *, session: Session, category_id: uuid.UUID +) -> RaceCategory | None: + """Get a race category by ID.""" + return session.get(RaceCategory, category_id) + + +def get_race_categories( + *, session: Session, race_id: uuid.UUID, skip: int = 0, limit: int = 100 +) -> list[RaceCategory]: + """Get all categories for a race.""" + statement = ( + select(RaceCategory) + .where(RaceCategory.race_id == race_id) + .order_by(col(RaceCategory.display_order), col(RaceCategory.distance_km)) + .offset(skip) + .limit(limit) + ) + return list(session.exec(statement).all()) + + +def update_race_category( + *, session: Session, db_category: RaceCategory, category_in: RaceCategoryUpdate +) -> RaceCategory: + """Update a race category.""" + category_data = category_in.model_dump(exclude_unset=True) + category_data["updated_at"] = datetime.now(timezone.utc) + db_category.sqlmodel_update(category_data) + session.add(db_category) + session.commit() + session.refresh(db_category) + return db_category + + +def delete_race_category(*, session: Session, category_id: uuid.UUID) -> bool: + """Delete a race category.""" + category = session.get(RaceCategory, category_id) + if category: + session.delete(category) + session.commit() + return True + return False + + +def get_category_registration_count(*, session: Session, category_id: uuid.UUID) -> int: + """Get registration count for a category.""" + statement = select(func.count(RaceRegistration.id)).where( + RaceRegistration.category_id == category_id + ) + return session.exec(statement).one() + + +def get_category_registration_window( + category: RaceCategory, race: Race +) -> tuple[datetime | None, datetime | None]: + """ + Get effective registration start/end for a category. + Category-specific dates override race defaults. + """ + start = category.registration_start or race.registration_start + end = category.registration_end or race.registration_end + return start, end + + +def is_category_registration_open( + category: RaceCategory, + race: Race, + session: Session, + check_time: datetime | None = None, +) -> bool: + """Check if registration is currently open for a category.""" + if check_time is None: + check_time = datetime.now(timezone.utc) + + start, end = get_category_registration_window(category, race) + + # Check if within registration window + if start and check_time < start: + return False + if end and check_time > end: + return False + + # Check if category is full + if category.max_participants: + count = get_category_registration_count( + session=session, category_id=category.id + ) + if count >= category.max_participants: + return False + + # Check if category and race are active + if not category.is_active or not race.is_active: + return False + + return True + + +def get_category_current_price( + category: RaceCategory, race: Race, check_time: datetime | None = None +) -> float | None: + """ + Get current price for a category. + Returns early bird price if applicable, otherwise regular price. + """ + if check_time is None: + check_time = datetime.now(timezone.utc) + + # Check early bird pricing + if ( + category.early_bird_price is not None + and category.early_bird_deadline + and check_time <= category.early_bird_deadline + ): + return category.early_bird_price + + # Return category price or race default + return category.price or race.base_price + + +def get_category_available_spots( + *, session: Session, category_id: uuid.UUID, max_participants: int | None +) -> int | None: + """Get number of available spots for a category.""" + if max_participants is None: + return None + + count = get_category_registration_count(session=session, category_id=category_id) + return max(0, max_participants - count) + + +# ============================================================================= +# RaceRegistration CRUD operations +# ============================================================================= + + +def create_race_registration( + *, + session: Session, + registration_in: RaceRegistrationCreate, + runner_id: uuid.UUID, +) -> RaceRegistration: + """Create a new race registration.""" + db_registration = RaceRegistration.model_validate( + registration_in, update={"runner_id": runner_id} + ) + session.add(db_registration) + session.commit() + session.refresh(db_registration) + return db_registration + + +def get_race_registration( + *, session: Session, registration_id: uuid.UUID +) -> RaceRegistration | None: + """Get a race registration by ID.""" + return session.get(RaceRegistration, registration_id) + + +def get_race_registrations( + *, + session: Session, + race_id: uuid.UUID | None = None, + runner_id: uuid.UUID | None = None, + category_id: uuid.UUID | None = None, + skip: int = 0, + limit: int = 100, +) -> list[RaceRegistration]: + """Get race registrations with filters.""" + statement = select(RaceRegistration).offset(skip).limit(limit) + + if race_id: + statement = statement.where(RaceRegistration.race_id == race_id) + if runner_id: + statement = statement.where(RaceRegistration.runner_id == runner_id) + if category_id: + statement = statement.where(RaceRegistration.category_id == category_id) + + statement = statement.order_by(col(RaceRegistration.registered_at).desc()) + return list(session.exec(statement).all()) + + +def get_race_registrations_count( + *, + session: Session, + race_id: uuid.UUID | None = None, + runner_id: uuid.UUID | None = None, + category_id: uuid.UUID | None = None, +) -> int: + """Get count of race registrations with filters.""" + statement = select(func.count(RaceRegistration.id)) + + if race_id: + statement = statement.where(RaceRegistration.race_id == race_id) + if runner_id: + statement = statement.where(RaceRegistration.runner_id == runner_id) + if category_id: + statement = statement.where(RaceRegistration.category_id == category_id) + + return session.exec(statement).one() + + +def update_race_registration( + *, + session: Session, + db_registration: RaceRegistration, + registration_in: RaceRegistrationUpdate, +) -> RaceRegistration: + """Update a race registration.""" + registration_data = registration_in.model_dump(exclude_unset=True) + registration_data["updated_at"] = datetime.now(timezone.utc) + db_registration.sqlmodel_update(registration_data) + session.add(db_registration) + session.commit() + session.refresh(db_registration) + return db_registration + + +def delete_race_registration(*, session: Session, registration_id: uuid.UUID) -> bool: + """Delete a race registration.""" + registration = session.get(RaceRegistration, registration_id) + if registration: + session.delete(registration) + session.commit() + return True + return False + + +def check_existing_registration( + *, session: Session, runner_id: uuid.UUID, race_id: uuid.UUID +) -> RaceRegistration | None: + """Check if a runner is already registered for a race.""" + statement = select(RaceRegistration).where( + RaceRegistration.runner_id == runner_id, + RaceRegistration.race_id == race_id, + ) + return session.exec(statement).first() + + +# ============================================================================= +# RaceResult CRUD operations +# ============================================================================= + + +def create_race_result(*, session: Session, result_in: RaceResultCreate) -> RaceResult: + """Create a new race result.""" + db_result = RaceResult.model_validate(result_in) + session.add(db_result) + session.commit() + session.refresh(db_result) + return db_result + + +def get_race_result(*, session: Session, result_id: uuid.UUID) -> RaceResult | None: + """Get a race result by ID.""" + return session.get(RaceResult, result_id) + + +def get_race_result_by_registration( + *, session: Session, registration_id: uuid.UUID +) -> RaceResult | None: + """Get race result by registration ID.""" + statement = select(RaceResult).where(RaceResult.registration_id == registration_id) + return session.exec(statement).first() + + +def get_race_results( + *, session: Session, race_id: uuid.UUID, skip: int = 0, limit: int = 100 +) -> list[RaceResult]: + """Get all results for a race.""" + statement = ( + select(RaceResult) + .join(RaceRegistration) + .where(RaceRegistration.race_id == race_id) + .order_by(col(RaceResult.overall_position)) + .offset(skip) + .limit(limit) + ) + return list(session.exec(statement).all()) + + +def update_race_result( + *, session: Session, db_result: RaceResult, result_in: RaceResultUpdate +) -> RaceResult: + """Update a race result.""" + result_data = result_in.model_dump(exclude_unset=True) + result_data["updated_at"] = datetime.now(timezone.utc) + db_result.sqlmodel_update(result_data) + session.add(db_result) + session.commit() + session.refresh(db_result) + return db_result + + +def delete_race_result(*, session: Session, result_id: uuid.UUID) -> bool: + """Delete a race result.""" + result = session.get(RaceResult, result_id) + if result: + session.delete(result) + session.commit() + return True + return False + + +# ============================================================================= +# RaceAttribute CRUD operations +# ============================================================================= + + +def create_race_attribute( + *, session: Session, attribute_in: RaceAttributeCreate +) -> RaceAttribute: + """Create a new race attribute.""" + db_attribute = RaceAttribute.model_validate(attribute_in) + session.add(db_attribute) + session.commit() + session.refresh(db_attribute) + return db_attribute + + +def get_race_attribute( + *, session: Session, attribute_id: uuid.UUID +) -> RaceAttribute | None: + """Get a race attribute by ID.""" + return session.get(RaceAttribute, attribute_id) + + +def get_race_attributes( + *, session: Session, race_id: uuid.UUID, is_public: bool | None = None +) -> list[RaceAttribute]: + """Get all attributes for a race.""" + statement = select(RaceAttribute).where(RaceAttribute.race_id == race_id) + + if is_public is not None: + statement = statement.where(RaceAttribute.is_public == is_public) + + statement = statement.order_by( + col(RaceAttribute.display_order), col(RaceAttribute.key) + ) + return list(session.exec(statement).all()) + + +def update_race_attribute( + *, session: Session, db_attribute: RaceAttribute, attribute_in: RaceAttributeUpdate +) -> RaceAttribute: + """Update a race attribute.""" + attribute_data = attribute_in.model_dump(exclude_unset=True) + attribute_data["updated_at"] = datetime.now(timezone.utc) + db_attribute.sqlmodel_update(attribute_data) + session.add(db_attribute) + session.commit() + session.refresh(db_attribute) + return db_attribute + + +def delete_race_attribute(*, session: Session, attribute_id: uuid.UUID) -> bool: + """Delete a race attribute.""" + attribute = session.get(RaceAttribute, attribute_id) + if attribute: + session.delete(attribute) + session.commit() + return True + return False + + +# ============================================================================= +# RaceCheckpoint and SplitTime CRUD operations +# ============================================================================= + + +def create_race_checkpoint( + *, session: Session, checkpoint_in: RaceCheckpointCreate +) -> RaceCheckpoint: + """Create a new race checkpoint.""" + db_checkpoint = RaceCheckpoint.model_validate(checkpoint_in) + session.add(db_checkpoint) + session.commit() + session.refresh(db_checkpoint) + return db_checkpoint + + +def get_race_checkpoint( + *, session: Session, checkpoint_id: uuid.UUID +) -> RaceCheckpoint | None: + """Get a race checkpoint by ID.""" + return session.get(RaceCheckpoint, checkpoint_id) + + +def get_race_checkpoints( + *, session: Session, race_id: uuid.UUID +) -> list[RaceCheckpoint]: + """Get all checkpoints for a race.""" + statement = ( + select(RaceCheckpoint) + .where(RaceCheckpoint.race_id == race_id) + .order_by(col(RaceCheckpoint.sequence)) + ) + return list(session.exec(statement).all()) + + +def update_race_checkpoint( + *, + session: Session, + db_checkpoint: RaceCheckpoint, + checkpoint_in: RaceCheckpointUpdate, +) -> RaceCheckpoint: + """Update a race checkpoint.""" + checkpoint_data = checkpoint_in.model_dump(exclude_unset=True) + db_checkpoint.sqlmodel_update(checkpoint_data) + session.add(db_checkpoint) + session.commit() + session.refresh(db_checkpoint) + return db_checkpoint + + +def delete_race_checkpoint(*, session: Session, checkpoint_id: uuid.UUID) -> bool: + """Delete a race checkpoint.""" + checkpoint = session.get(RaceCheckpoint, checkpoint_id) + if checkpoint: + session.delete(checkpoint) + session.commit() + return True + return False + + +def create_race_split_time( + *, session: Session, split_time_in: RaceSplitTimeCreate +) -> RaceSplitTime: + """Create a new split time.""" + db_split_time = RaceSplitTime.model_validate(split_time_in) + session.add(db_split_time) + session.commit() + session.refresh(db_split_time) + return db_split_time + + +def get_registration_split_times( + *, session: Session, registration_id: uuid.UUID +) -> list[RaceSplitTime]: + """Get all split times for a registration.""" + statement = ( + select(RaceSplitTime) + .where(RaceSplitTime.registration_id == registration_id) + .order_by(col(RaceSplitTime.recorded_at)) + ) + return list(session.exec(statement).all()) diff --git a/backend/app/models.py b/backend/app/models.py index bba0a8c2e6..360ff52146 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,9 +1,10 @@ import uuid from datetime import datetime, timezone from enum import Enum +from typing import Any, Optional from pydantic import EmailStr -from sqlalchemy import Column, DateTime +from sqlalchemy import JSON, Column, DateTime, Text from sqlmodel import Field, Relationship, SQLModel @@ -19,6 +20,53 @@ class RoleEnum(str, Enum): VOLUNTEER = "volunteer" +# Enum for race status +class RaceStatusEnum(str, Enum): + DRAFT = "draft" + PUBLISHED = "published" + REGISTRATION_OPEN = "registration_open" + REGISTRATION_CLOSED = "registration_closed" + COMPLETED = "completed" + CANCELLED = "cancelled" + + +# Enum for registration status +class RegistrationStatusEnum(str, Enum): + PENDING = "pending" + CONFIRMED = "confirmed" + CANCELLED = "cancelled" + WAITLIST = "waitlist" + + +# Enum for payment status +class PaymentStatusEnum(str, Enum): + UNPAID = "unpaid" + PAID = "paid" + REFUNDED = "refunded" + PARTIAL = "partial" + + +# Enum for race result status +class ResultStatusEnum(str, Enum): + FINISHED = "finished" + DNF = "dnf" # Did Not Finish + DNS = "dns" # Did Not Start + DQ = "dq" # Disqualified + + +# Enum for flexible attribute types +class AttributeTypeEnum(str, Enum): + STRING = "string" + TEXT = "text" + URL = "url" + DATE = "date" + DATETIME = "datetime" + NUMBER = "number" + BOOLEAN = "boolean" + EMAIL = "email" + PHONE = "phone" + + # Link table for many-to-many relationship between User and Role class UserRoleLink(SQLModel, table=True): user_id: uuid.UUID = Field( @@ -108,6 +156,13 @@ class User(UserBase, table=True): ) items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True) roles: list[Role] = Relationship(back_populates="users", link_model=UserRoleLink) + # Race relationships + organized_races: list["Race"] = Relationship( + back_populates="organizer", cascade_delete=True + ) + race_registrations: list["RaceRegistration"] = Relationship( + back_populates="runner", cascade_delete=True + ) # Properties to return via API, id is always required @@ -163,6 +218,593 @@ class ItemsPublic(SQLModel): count: int +# ============================================================================= +# MediaAsset - Reusable media for any content type (race, article, etc.) +# ============================================================================= + + +class MediaAssetBase(SQLModel): + content_type: str = Field(max_length=100, index=True) + content_id: uuid.UUID = Field(index=True) + kind: str = Field(default="gallery", max_length=50, index=True) + + alt_text: str | None = Field(default=None, max_length=255) + display_order: int = Field(default=0) + is_primary: bool = False + is_public: bool = True + + +class MediaAssetCreate(MediaAssetBase): + original_filename: str = Field(max_length=255) + file_name: str = Field(max_length=255) + file_path: str = Field(max_length=1000) + file_url: str = Field(max_length=1000) + mime_type: str = Field(max_length=100) + size_bytes: int = Field(ge=0) + uploaded_by_id: uuid.UUID | None = None + + +class MediaAssetUpdate(SQLModel): + kind: str | None = Field(default=None, max_length=50) + alt_text: str | None = Field(default=None, max_length=255) + display_order: int | None = None + is_primary: bool | None = None + is_public: bool | None = None + + +class MediaAsset(MediaAssetBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + original_filename: str = Field(max_length=255) + file_name: str = Field(max_length=255) + file_path: str = Field(max_length=1000) + file_url: str = Field(max_length=1000) + mime_type: str = Field(max_length=100) + size_bytes: int = Field(ge=0) + uploaded_by_id: uuid.UUID | None = Field(default=None, foreign_key="user.id") + created_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + updated_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + + +class MediaAssetPublic(MediaAssetBase): + id: uuid.UUID + original_filename: str + file_name: str + file_url: str + mime_type: str + size_bytes: int + uploaded_by_id: uuid.UUID | None = None + created_at: datetime + updated_at: datetime + + +class MediaAssetsPublic(SQLModel): + data: list[MediaAssetPublic] + count: int + + +# ============================================================================= +# Race Models +# ============================================================================= + + +# Race - Main race event +class RaceBase(SQLModel): + name: str = Field(min_length=1, max_length=255, index=True) + description: str | None = Field(default=None, max_length=2000) + + # Overall event period (for multi-day events) + event_start_date: datetime = Field(sa_column=Column(DateTime(timezone=True))) + event_end_date: datetime | None = Field( + default=None, sa_column=Column(DateTime(timezone=True)) + ) + + # Location + location: str = Field(max_length=255) + city: str | None = Field(default=None, max_length=100) + state: str | None = Field(default=None, max_length=100) + country: str = Field(default="USA", max_length=100) + + # Overall registration (can be overridden per category) + registration_start: datetime | None = Field( + default=None, sa_column=Column(DateTime(timezone=True)) + ) + registration_end: datetime | None = Field( + default=None, sa_column=Column(DateTime(timezone=True)) + ) + + # Status + status: RaceStatusEnum = Field(default=RaceStatusEnum.DRAFT) + is_active: bool = True + + # Default pricing (can be overridden per category) + base_price: float | None = Field(default=None, ge=0) + currency: str = Field(default="USD", max_length=3) + + # Flexible metadata stored as JSON + race_metadata: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON)) + + +class RaceCreate(RaceBase): + pass + + +class RaceUpdate(SQLModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = None + event_start_date: datetime | None = None + event_end_date: datetime | None = None + location: str | None = None + city: str | None = None + state: str | None = None + country: str | None = None + registration_start: datetime | None = None + registration_end: datetime | None = None + status: RaceStatusEnum | None = None + is_active: bool | None = None + base_price: float | None = None + currency: str | None = None + race_metadata: dict[str, Any] | None = None + + +class Race(RaceBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + created_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + updated_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + + # Foreign keys + organizer_id: uuid.UUID = Field(foreign_key="user.id", nullable=False) + + # Relationships + organizer: User = Relationship(back_populates="organized_races") + categories: list["RaceCategory"] = Relationship( + back_populates="race", cascade_delete=True + ) + registrations: list["RaceRegistration"] = Relationship( + back_populates="race", cascade_delete=True + ) + attributes: list["RaceAttribute"] = Relationship( + back_populates="race", cascade_delete=True + ) + checkpoints: list["RaceCheckpoint"] = Relationship( + back_populates="race", cascade_delete=True + ) + + +class RacePublic(RaceBase): + id: uuid.UUID + created_at: datetime + updated_at: datetime + organizer_id: uuid.UUID + + +class RacePublicWithDetails(RacePublic): + categories: list["RaceCategoryPublic"] = [] + registration_count: int = 0 + + +class RacesPublic(SQLModel): + data: list[RacePublic] + count: int + + +# RaceCategory - Distance/Type variations +class RaceCategoryBase(SQLModel): + name: str = Field(max_length=100) + distance_km: float = Field(gt=0) + distance_unit: str = Field(default="km", max_length=10) + + # Category-specific start and end times + start_time: datetime = Field(sa_column=Column(DateTime(timezone=True))) + end_time: datetime | None = Field( + default=None, sa_column=Column(DateTime(timezone=True)) + ) + + # Time limits + cutoff_time_minutes: int | None = Field(default=None, ge=0) + + # Category-specific registration window (overrides race defaults) + registration_start: datetime | None = Field( + default=None, sa_column=Column(DateTime(timezone=True)) + ) + registration_end: datetime | None = Field( + default=None, sa_column=Column(DateTime(timezone=True)) + ) + + # Category-specific pricing + price: float | None = Field(default=None, ge=0) + early_bird_price: float | None = Field(default=None, ge=0) + early_bird_deadline: datetime | None = Field( + default=None, sa_column=Column(DateTime(timezone=True)) + ) + + # Capacity + max_participants: int | None = Field(default=None, ge=1) + + # Age/gender restrictions + min_age: int | None = Field(default=None, ge=0) + max_age: int | None = Field(default=None, ge=0) + gender_restriction: str | None = Field(default=None, max_length=20) + + # Display + description: str | None = Field(default=None, max_length=500) + display_order: int = Field(default=0) + is_active: bool = True + + +class RaceCategoryCreate(RaceCategoryBase): + race_id: uuid.UUID + + +class RaceCategoryUpdate(SQLModel): + name: str | None = None + distance_km: float | None = None + distance_unit: str | None = None + start_time: datetime | None = None + end_time: datetime | None = None + cutoff_time_minutes: int | None = None + registration_start: datetime | None = None + registration_end: datetime | None = None + price: float | None = None + early_bird_price: float | None = None + early_bird_deadline: datetime | None = None + max_participants: int | None = None + min_age: int | None = None + max_age: int | None = None + gender_restriction: str | None = None + description: str | None = None + display_order: int | None = None + is_active: bool | None = None + + +class RaceCategory(RaceCategoryBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + race_id: uuid.UUID = Field( + foreign_key="race.id", nullable=False, ondelete="CASCADE" + ) + created_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + updated_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + + # Relationships + race: Race = Relationship(back_populates="categories") + registrations: list["RaceRegistration"] = Relationship( + back_populates="category", cascade_delete=True + ) + + +class RaceCategoryPublic(RaceCategoryBase): + id: uuid.UUID + race_id: uuid.UUID + created_at: datetime + updated_at: datetime + + +class RaceCategoryPublicWithDetails(RaceCategoryPublic): + registration_count: int = 0 + available_spots: int | None = None + is_registration_open: bool = False + current_price: float | None = None + + +class RaceCategoriesPublic(SQLModel): + data: list[RaceCategoryPublic] + count: int + + +# RaceRegistration - Runner registrations +class RaceRegistrationBase(SQLModel): + # Runner information + bib_number: str | None = Field(default=None, max_length=50) + emergency_contact: str | None = Field(default=None, max_length=255) + emergency_phone: str | None = Field(default=None, max_length=50) + + # Additional info + tshirt_size: str | None = Field(default=None, max_length=10) + special_requirements: str | None = Field(default=None, max_length=500) + + # Payment & status + registration_status: RegistrationStatusEnum = Field( + default=RegistrationStatusEnum.PENDING + ) + payment_status: PaymentStatusEnum = Field(default=PaymentStatusEnum.UNPAID) + amount_paid: float | None = Field(default=None, ge=0) + payment_reference: str | None = Field(default=None, max_length=255) + + # Extra data stored as JSON + registration_data: dict[str, Any] | None = Field( + default=None, sa_column=Column(JSON) + ) + + +class RaceRegistrationCreate(RaceRegistrationBase): + race_id: uuid.UUID + category_id: uuid.UUID + + +class RaceRegistrationUpdate(SQLModel): + bib_number: str | None = None + emergency_contact: str | None = None + emergency_phone: str | None = None + tshirt_size: str | None = None + special_requirements: str | None = None + registration_status: RegistrationStatusEnum | None = None + payment_status: PaymentStatusEnum | None = None + amount_paid: float | None = None + payment_reference: str | None = None + registration_data: dict[str, Any] | None = None + + +class RaceRegistration(RaceRegistrationBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + race_id: uuid.UUID = Field( + foreign_key="race.id", nullable=False, ondelete="CASCADE" + ) + category_id: uuid.UUID = Field( + foreign_key="racecategory.id", nullable=False, ondelete="CASCADE" + ) + runner_id: uuid.UUID = Field( + foreign_key="user.id", nullable=False, ondelete="CASCADE" + ) + + registered_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + updated_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + + # Relationships + race: Race = Relationship(back_populates="registrations") + category: RaceCategory = Relationship(back_populates="registrations") + runner: User = Relationship(back_populates="race_registrations") + result: Optional["RaceResult"] = Relationship( + back_populates="registration", sa_relationship_kwargs={"uselist": False} + ) + split_times: list["RaceSplitTime"] = Relationship( + back_populates="registration", cascade_delete=True + ) + + +class RaceRegistrationPublic(RaceRegistrationBase): + id: uuid.UUID + race_id: uuid.UUID + category_id: uuid.UUID + runner_id: uuid.UUID + registered_at: datetime + updated_at: datetime + + +class RaceRegistrationPublicWithDetails(RaceRegistrationPublic): + runner: UserPublic + category: RaceCategoryPublic + + +class RaceRegistrationsPublic(SQLModel): + data: list[RaceRegistrationPublic] + count: int + + +# RaceResult - Race completion results +class RaceResultBase(SQLModel): + finish_time_seconds: int | None = Field(default=None, ge=0) + overall_position: int | None = Field(default=None, ge=1) + category_position: int | None = Field(default=None, ge=1) + gender_position: int | None = Field(default=None, ge=1) + + status: ResultStatusEnum = Field(default=ResultStatusEnum.FINISHED) + + # Calculated fields + pace_per_km_seconds: float | None = None + notes: str | None = Field(default=None, max_length=500) + + +class RaceResultCreate(RaceResultBase): + registration_id: uuid.UUID + + +class RaceResultUpdate(RaceResultBase): + finish_time_seconds: int | None = None + overall_position: int | None = None + category_position: int | None = None + gender_position: int | None = None + status: ResultStatusEnum | None = None + pace_per_km_seconds: float | None = None + notes: str | None = None + + +class RaceResult(RaceResultBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + registration_id: uuid.UUID = Field( + foreign_key="raceregistration.id", + unique=True, + nullable=False, + ondelete="CASCADE", + ) + + created_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + updated_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + + # Relationships + registration: RaceRegistration = Relationship(back_populates="result") + + +class RaceResultPublic(RaceResultBase): + id: uuid.UUID + registration_id: uuid.UUID + created_at: datetime + updated_at: datetime + + +class RaceResultsPublic(SQLModel): + data: list[RaceResultPublic] + count: int + + +# RaceAttribute - Flexible key-value attributes +class RaceAttributeBase(SQLModel): + key: str = Field(max_length=100, index=True) + value_text: str | None = Field(default=None, sa_column=Column(Text)) + + # Metadata about the attribute + attribute_type: AttributeTypeEnum = Field(default=AttributeTypeEnum.STRING) + label: str | None = Field(default=None, max_length=255) + description: str | None = Field(default=None, max_length=500) + is_required: bool = False + is_public: bool = True + display_order: int = Field(default=0) + + +class RaceAttributeCreate(RaceAttributeBase): + race_id: uuid.UUID + + +class RaceAttributeUpdate(SQLModel): + value_text: str | None = None + attribute_type: AttributeTypeEnum | None = None + label: str | None = None + description: str | None = None + is_required: bool | None = None + is_public: bool | None = None + display_order: int | None = None + + +class RaceAttribute(RaceAttributeBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + race_id: uuid.UUID = Field( + foreign_key="race.id", nullable=False, ondelete="CASCADE" + ) + created_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + updated_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + + # Relationships + race: Race = Relationship(back_populates="attributes") + + +class RaceAttributePublic(RaceAttributeBase): + id: uuid.UUID + race_id: uuid.UUID + created_at: datetime + updated_at: datetime + + +class RaceAttributesPublic(SQLModel): + data: list[RaceAttributePublic] + count: int + + +# RaceCheckpoint - For split times tracking +class RaceCheckpointBase(SQLModel): + name: str = Field(max_length=100) + distance_km: float = Field(ge=0) + sequence: int = Field(ge=1) + is_active: bool = True + + +class RaceCheckpointCreate(RaceCheckpointBase): + race_id: uuid.UUID + + +class RaceCheckpointUpdate(SQLModel): + name: str | None = None + distance_km: float | None = None + sequence: int | None = None + is_active: bool | None = None + + +class RaceCheckpoint(RaceCheckpointBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + race_id: uuid.UUID = Field( + foreign_key="race.id", nullable=False, ondelete="CASCADE" + ) + created_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + + # Relationships + race: Race = Relationship(back_populates="checkpoints") + split_times: list["RaceSplitTime"] = Relationship( + back_populates="checkpoint", cascade_delete=True + ) + + +class RaceCheckpointPublic(RaceCheckpointBase): + id: uuid.UUID + race_id: uuid.UUID + created_at: datetime + + +class RaceCheckpointsPublic(SQLModel): + data: list[RaceCheckpointPublic] + count: int + + +# RaceSplitTime - Split times at checkpoints +class RaceSplitTimeBase(SQLModel): + time_seconds: int = Field(ge=0) + + +class RaceSplitTimeCreate(RaceSplitTimeBase): + registration_id: uuid.UUID + checkpoint_id: uuid.UUID + + +class RaceSplitTimeUpdate(SQLModel): + time_seconds: int | None = None + + +class RaceSplitTime(RaceSplitTimeBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + registration_id: uuid.UUID = Field( + foreign_key="raceregistration.id", nullable=False, ondelete="CASCADE" + ) + checkpoint_id: uuid.UUID = Field( + foreign_key="racecheckpoint.id", nullable=False, ondelete="CASCADE" + ) + recorded_at: datetime = Field( + default_factory=get_datetime_utc, sa_column=Column(DateTime(timezone=True)) + ) + + # Relationships + registration: RaceRegistration = Relationship(back_populates="split_times") + checkpoint: RaceCheckpoint = Relationship(back_populates="split_times") + + +class RaceSplitTimePublic(RaceSplitTimeBase): + id: uuid.UUID + registration_id: uuid.UUID + checkpoint_id: uuid.UUID + recorded_at: datetime + + +class RaceSplitTimesPublic(SQLModel): + data: list[RaceSplitTimePublic] + count: int + + +# ============================================================================= +# End of Race Models +# ============================================================================= + + # Generic message class Message(SQLModel): message: str diff --git a/backend/app/services/media_storage.py b/backend/app/services/media_storage.py new file mode 100644 index 0000000000..fdc2c74b70 --- /dev/null +++ b/backend/app/services/media_storage.py @@ -0,0 +1,57 @@ +import uuid +from pathlib import Path + +from fastapi import UploadFile + +from app.core.config import settings + + +def _safe_extension(filename: str) -> str: + ext = Path(filename).suffix.lower().strip() + if not ext: + return ".bin" + if len(ext) > 10: + return ".bin" + return ext + + +def get_media_storage_root() -> Path: + root = Path(settings.MEDIA_UPLOAD_DIR) + if not root.is_absolute(): + root = Path(__file__).resolve().parents[2] / root + root.mkdir(parents=True, exist_ok=True) + return root + + +def save_uploaded_media( + *, + file: UploadFile, + content_type: str, + content_id: uuid.UUID, +) -> tuple[str, str, int]: + """Save file and return (stored_filename, relative_path, size_bytes).""" + storage_root = get_media_storage_root() + target_dir = storage_root / content_type / str(content_id) + target_dir.mkdir(parents=True, exist_ok=True) + + ext = _safe_extension(file.filename or "") + stored_filename = f"{uuid.uuid4().hex}{ext}" + target_path = target_dir / stored_filename + + content = file.file.read() + size_bytes = len(content) + target_path.write_bytes(content) + + relative_path = str(target_path.relative_to(storage_root)) + return stored_filename, relative_path, size_bytes + + +def resolve_media_path(file_path: str) -> Path: + storage_root = get_media_storage_root() + return storage_root / file_path + + +def delete_media_file(file_path: str) -> None: + target = resolve_media_path(file_path) + if target.exists() and target.is_file(): + target.unlink() diff --git a/bun.lock b/bun.lock index 34d6b22a9b..e1f8b48cd9 100644 --- a/bun.lock +++ b/bun.lock @@ -31,9 +31,11 @@ "axios": "1.13.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "form-data": "4.0.5", "lucide-react": "^0.563.0", "next-themes": "^0.4.6", + "radix-ui": "^1.4.3", "react": "^19.1.1", "react-dom": "^19.2.3", "react-error-boundary": "^6.0.0", @@ -202,18 +204,30 @@ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + "@radix-ui/react-accessible-icon": ["@radix-ui/react-accessible-icon@1.1.7", "", { "dependencies": { "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A=="], + + "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="], + + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="], + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], + "@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g=="], + "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.11", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q=="], "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="], + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="], + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], "@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="], + "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww=="], + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], @@ -226,12 +240,26 @@ "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + "@radix-ui/react-form": ["@radix-ui/react-form@0.1.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ=="], + + "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg=="], + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], "@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="], "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="], + "@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA=="], + + "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w=="], + + "@radix-ui/react-one-time-password-field": ["@radix-ui/react-one-time-password-field@0.1.8", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg=="], + + "@radix-ui/react-password-toggle-field": ["@radix-ui/react-password-toggle-field@0.1.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw=="], + + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="], + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], @@ -240,6 +268,8 @@ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="], + "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="], "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], @@ -250,10 +280,22 @@ "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="], + "@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="], + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="], + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], + "@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g=="], + + "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="], + + "@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q=="], + + "@radix-ui/react-toolbar": ["@radix-ui/react-toolbar@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-toggle-group": "1.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg=="], + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], @@ -498,6 +540,8 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "default-browser": ["default-browser@5.4.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg=="], @@ -698,6 +742,8 @@ "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + "radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="], + "rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="], "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], @@ -792,18 +838,38 @@ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@radix-ui/react-accordion/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-accordion/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-alert-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-alert-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-aspect-ratio/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-checkbox/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-checkbox/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-collapsible/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-collapsible/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-collection/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-collection/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-context-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-context-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], @@ -818,18 +884,54 @@ "@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-form/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-form/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], + + "@radix-ui/react-form/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-hover-card/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-hover-card/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-menubar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-menubar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-navigation-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-navigation-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-one-time-password-field/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-one-time-password-field/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-password-toggle-field/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-password-toggle-field/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-popover/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-popover/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-popper/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-progress/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-progress/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-radio-group/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-radio-group/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], @@ -848,10 +950,34 @@ "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-slider/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-slider/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-switch/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-switch/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-tabs/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-tabs/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-toast/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-toast/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-toggle/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-toggle-group/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-toggle-group/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-toolbar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-toolbar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-toolbar/@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="], + "@radix-ui/react-tooltip/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-tooltip/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], @@ -902,30 +1028,76 @@ "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "radix-ui/@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="], + + "radix-ui/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "radix-ui/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], + + "radix-ui/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "radix-ui/@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="], + + "radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "@radix-ui/react-accordion/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-aspect-ratio/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-checkbox/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-collapsible/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-context-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-dropdown-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-focus-scope/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-form/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-hover-card/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-menubar/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-navigation-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-one-time-password-field/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-password-toggle-field/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-popper/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-progress/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-radio-group/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-roving-focus/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-scroll-area/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-slider/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-switch/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-tabs/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-toast/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-toggle-group/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-toggle/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-toolbar/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@tanstack/react-router/@tanstack/router-core/@tanstack/store": ["@tanstack/store@0.9.1", "", {}, "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg=="], diff --git a/frontend/package.json b/frontend/package.json index f3b8d23e24..45656c4fa9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,9 +35,11 @@ "axios": "1.13.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "form-data": "4.0.5", "lucide-react": "^0.563.0", "next-themes": "^0.4.6", + "radix-ui": "^1.4.3", "react": "^19.1.1", "react-dom": "^19.2.3", "react-error-boundary": "^6.0.0", diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index f61c1f788a..1d23859fed 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -1,5 +1,11 @@ // This file is auto-generated by @hey-api/openapi-ts +export const AttributeTypeEnumSchema = { + type: 'string', + enum: ['string', 'text', 'url', 'date', 'datetime', 'number', 'boolean', 'email', 'phone'], + title: 'AttributeTypeEnum' +} as const; + export const Body_login_login_access_tokenSchema = { properties: { grant_type: { @@ -226,6 +232,12 @@ export const NewPasswordSchema = { title: 'NewPassword' } as const; +export const PaymentStatusEnumSchema = { + type: 'string', + enum: ['unpaid', 'paid', 'refunded', 'partial'], + title: 'PaymentStatusEnum' +} as const; + export const PrivateUserCreateSchema = { properties: { email: { @@ -251,6 +263,2652 @@ export const PrivateUserCreateSchema = { title: 'PrivateUserCreate' } as const; +export const RaceAttributeCreateSchema = { + properties: { + key: { + type: 'string', + maxLength: 100, + title: 'Key' + }, + value_text: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Value Text' + }, + attribute_type: { + '$ref': '#/components/schemas/AttributeTypeEnum', + default: 'string' + }, + label: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Label' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + is_required: { + type: 'boolean', + title: 'Is Required', + default: false + }, + is_public: { + type: 'boolean', + title: 'Is Public', + default: true + }, + display_order: { + type: 'integer', + title: 'Display Order', + default: 0 + }, + race_id: { + type: 'string', + format: 'uuid', + title: 'Race Id' + } + }, + type: 'object', + required: ['key', 'race_id'], + title: 'RaceAttributeCreate' +} as const; + +export const RaceAttributePublicSchema = { + properties: { + key: { + type: 'string', + maxLength: 100, + title: 'Key' + }, + value_text: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Value Text' + }, + attribute_type: { + '$ref': '#/components/schemas/AttributeTypeEnum', + default: 'string' + }, + label: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Label' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + is_required: { + type: 'boolean', + title: 'Is Required', + default: false + }, + is_public: { + type: 'boolean', + title: 'Is Public', + default: true + }, + display_order: { + type: 'integer', + title: 'Display Order', + default: 0 + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + race_id: { + type: 'string', + format: 'uuid', + title: 'Race Id' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + } + }, + type: 'object', + required: ['key', 'id', 'race_id', 'created_at', 'updated_at'], + title: 'RaceAttributePublic' +} as const; + +export const RaceAttributeUpdateSchema = { + properties: { + value_text: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Value Text' + }, + attribute_type: { + anyOf: [ + { + '$ref': '#/components/schemas/AttributeTypeEnum' + }, + { + type: 'null' + } + ] + }, + label: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Label' + }, + description: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Description' + }, + is_required: { + anyOf: [ + { + type: 'boolean' + }, + { + type: 'null' + } + ], + title: 'Is Required' + }, + is_public: { + anyOf: [ + { + type: 'boolean' + }, + { + type: 'null' + } + ], + title: 'Is Public' + }, + display_order: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Display Order' + } + }, + type: 'object', + title: 'RaceAttributeUpdate' +} as const; + +export const RaceAttributesPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/RaceAttributePublic' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'RaceAttributesPublic' +} as const; + +export const RaceCategoriesPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/RaceCategoryPublic' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'RaceCategoriesPublic' +} as const; + +export const RaceCategoryCreateSchema = { + properties: { + name: { + type: 'string', + maxLength: 100, + title: 'Name' + }, + distance_km: { + type: 'number', + exclusiveMinimum: 0, + title: 'Distance Km' + }, + distance_unit: { + type: 'string', + maxLength: 10, + title: 'Distance Unit', + default: 'km' + }, + start_time: { + type: 'string', + format: 'date-time', + title: 'Start Time' + }, + end_time: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'End Time' + }, + cutoff_time_minutes: { + anyOf: [ + { + type: 'integer', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Cutoff Time Minutes' + }, + registration_start: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration Start' + }, + registration_end: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration End' + }, + price: { + anyOf: [ + { + type: 'number', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Price' + }, + early_bird_price: { + anyOf: [ + { + type: 'number', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Early Bird Price' + }, + early_bird_deadline: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Early Bird Deadline' + }, + max_participants: { + anyOf: [ + { + type: 'integer', + minimum: 1 + }, + { + type: 'null' + } + ], + title: 'Max Participants' + }, + min_age: { + anyOf: [ + { + type: 'integer', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Min Age' + }, + max_age: { + anyOf: [ + { + type: 'integer', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Max Age' + }, + gender_restriction: { + anyOf: [ + { + type: 'string', + maxLength: 20 + }, + { + type: 'null' + } + ], + title: 'Gender Restriction' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + display_order: { + type: 'integer', + title: 'Display Order', + default: 0 + }, + is_active: { + type: 'boolean', + title: 'Is Active', + default: true + }, + race_id: { + type: 'string', + format: 'uuid', + title: 'Race Id' + } + }, + type: 'object', + required: ['name', 'distance_km', 'start_time', 'race_id'], + title: 'RaceCategoryCreate' +} as const; + +export const RaceCategoryPublicSchema = { + properties: { + name: { + type: 'string', + maxLength: 100, + title: 'Name' + }, + distance_km: { + type: 'number', + exclusiveMinimum: 0, + title: 'Distance Km' + }, + distance_unit: { + type: 'string', + maxLength: 10, + title: 'Distance Unit', + default: 'km' + }, + start_time: { + type: 'string', + format: 'date-time', + title: 'Start Time' + }, + end_time: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'End Time' + }, + cutoff_time_minutes: { + anyOf: [ + { + type: 'integer', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Cutoff Time Minutes' + }, + registration_start: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration Start' + }, + registration_end: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration End' + }, + price: { + anyOf: [ + { + type: 'number', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Price' + }, + early_bird_price: { + anyOf: [ + { + type: 'number', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Early Bird Price' + }, + early_bird_deadline: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Early Bird Deadline' + }, + max_participants: { + anyOf: [ + { + type: 'integer', + minimum: 1 + }, + { + type: 'null' + } + ], + title: 'Max Participants' + }, + min_age: { + anyOf: [ + { + type: 'integer', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Min Age' + }, + max_age: { + anyOf: [ + { + type: 'integer', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Max Age' + }, + gender_restriction: { + anyOf: [ + { + type: 'string', + maxLength: 20 + }, + { + type: 'null' + } + ], + title: 'Gender Restriction' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + display_order: { + type: 'integer', + title: 'Display Order', + default: 0 + }, + is_active: { + type: 'boolean', + title: 'Is Active', + default: true + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + race_id: { + type: 'string', + format: 'uuid', + title: 'Race Id' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + } + }, + type: 'object', + required: ['name', 'distance_km', 'start_time', 'id', 'race_id', 'created_at', 'updated_at'], + title: 'RaceCategoryPublic' +} as const; + +export const RaceCategoryPublicWithDetailsSchema = { + properties: { + name: { + type: 'string', + maxLength: 100, + title: 'Name' + }, + distance_km: { + type: 'number', + exclusiveMinimum: 0, + title: 'Distance Km' + }, + distance_unit: { + type: 'string', + maxLength: 10, + title: 'Distance Unit', + default: 'km' + }, + start_time: { + type: 'string', + format: 'date-time', + title: 'Start Time' + }, + end_time: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'End Time' + }, + cutoff_time_minutes: { + anyOf: [ + { + type: 'integer', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Cutoff Time Minutes' + }, + registration_start: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration Start' + }, + registration_end: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration End' + }, + price: { + anyOf: [ + { + type: 'number', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Price' + }, + early_bird_price: { + anyOf: [ + { + type: 'number', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Early Bird Price' + }, + early_bird_deadline: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Early Bird Deadline' + }, + max_participants: { + anyOf: [ + { + type: 'integer', + minimum: 1 + }, + { + type: 'null' + } + ], + title: 'Max Participants' + }, + min_age: { + anyOf: [ + { + type: 'integer', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Min Age' + }, + max_age: { + anyOf: [ + { + type: 'integer', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Max Age' + }, + gender_restriction: { + anyOf: [ + { + type: 'string', + maxLength: 20 + }, + { + type: 'null' + } + ], + title: 'Gender Restriction' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + display_order: { + type: 'integer', + title: 'Display Order', + default: 0 + }, + is_active: { + type: 'boolean', + title: 'Is Active', + default: true + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + race_id: { + type: 'string', + format: 'uuid', + title: 'Race Id' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + }, + registration_count: { + type: 'integer', + title: 'Registration Count', + default: 0 + }, + available_spots: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Available Spots' + }, + is_registration_open: { + type: 'boolean', + title: 'Is Registration Open', + default: false + }, + current_price: { + anyOf: [ + { + type: 'number' + }, + { + type: 'null' + } + ], + title: 'Current Price' + } + }, + type: 'object', + required: ['name', 'distance_km', 'start_time', 'id', 'race_id', 'created_at', 'updated_at'], + title: 'RaceCategoryPublicWithDetails' +} as const; + +export const RaceCategoryUpdateSchema = { + properties: { + name: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Name' + }, + distance_km: { + anyOf: [ + { + type: 'number' + }, + { + type: 'null' + } + ], + title: 'Distance Km' + }, + distance_unit: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Distance Unit' + }, + start_time: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Start Time' + }, + end_time: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'End Time' + }, + cutoff_time_minutes: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Cutoff Time Minutes' + }, + registration_start: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration Start' + }, + registration_end: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration End' + }, + price: { + anyOf: [ + { + type: 'number' + }, + { + type: 'null' + } + ], + title: 'Price' + }, + early_bird_price: { + anyOf: [ + { + type: 'number' + }, + { + type: 'null' + } + ], + title: 'Early Bird Price' + }, + early_bird_deadline: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Early Bird Deadline' + }, + max_participants: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Max Participants' + }, + min_age: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Min Age' + }, + max_age: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Max Age' + }, + gender_restriction: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Gender Restriction' + }, + description: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Description' + }, + display_order: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Display Order' + }, + is_active: { + anyOf: [ + { + type: 'boolean' + }, + { + type: 'null' + } + ], + title: 'Is Active' + } + }, + type: 'object', + title: 'RaceCategoryUpdate' +} as const; + +export const RaceCreateSchema = { + properties: { + name: { + type: 'string', + maxLength: 255, + minLength: 1, + title: 'Name' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 2000 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + event_start_date: { + type: 'string', + format: 'date-time', + title: 'Event Start Date' + }, + event_end_date: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Event End Date' + }, + location: { + type: 'string', + maxLength: 255, + title: 'Location' + }, + city: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'City' + }, + state: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'State' + }, + country: { + type: 'string', + maxLength: 100, + title: 'Country', + default: 'USA' + }, + registration_start: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration Start' + }, + registration_end: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration End' + }, + status: { + '$ref': '#/components/schemas/RaceStatusEnum', + default: 'draft' + }, + is_active: { + type: 'boolean', + title: 'Is Active', + default: true + }, + base_price: { + anyOf: [ + { + type: 'number', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Base Price' + }, + currency: { + type: 'string', + maxLength: 3, + title: 'Currency', + default: 'USD' + }, + race_metadata: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Race Metadata' + } + }, + type: 'object', + required: ['name', 'event_start_date', 'location'], + title: 'RaceCreate' +} as const; + +export const RacePublicSchema = { + properties: { + name: { + type: 'string', + maxLength: 255, + minLength: 1, + title: 'Name' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 2000 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + event_start_date: { + type: 'string', + format: 'date-time', + title: 'Event Start Date' + }, + event_end_date: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Event End Date' + }, + location: { + type: 'string', + maxLength: 255, + title: 'Location' + }, + city: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'City' + }, + state: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'State' + }, + country: { + type: 'string', + maxLength: 100, + title: 'Country', + default: 'USA' + }, + registration_start: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration Start' + }, + registration_end: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration End' + }, + status: { + '$ref': '#/components/schemas/RaceStatusEnum', + default: 'draft' + }, + is_active: { + type: 'boolean', + title: 'Is Active', + default: true + }, + base_price: { + anyOf: [ + { + type: 'number', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Base Price' + }, + currency: { + type: 'string', + maxLength: 3, + title: 'Currency', + default: 'USD' + }, + race_metadata: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Race Metadata' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + }, + organizer_id: { + type: 'string', + format: 'uuid', + title: 'Organizer Id' + } + }, + type: 'object', + required: ['name', 'event_start_date', 'location', 'id', 'created_at', 'updated_at', 'organizer_id'], + title: 'RacePublic' +} as const; + +export const RacePublicWithDetailsSchema = { + properties: { + name: { + type: 'string', + maxLength: 255, + minLength: 1, + title: 'Name' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 2000 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + event_start_date: { + type: 'string', + format: 'date-time', + title: 'Event Start Date' + }, + event_end_date: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Event End Date' + }, + location: { + type: 'string', + maxLength: 255, + title: 'Location' + }, + city: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'City' + }, + state: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'State' + }, + country: { + type: 'string', + maxLength: 100, + title: 'Country', + default: 'USA' + }, + registration_start: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration Start' + }, + registration_end: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration End' + }, + status: { + '$ref': '#/components/schemas/RaceStatusEnum', + default: 'draft' + }, + is_active: { + type: 'boolean', + title: 'Is Active', + default: true + }, + base_price: { + anyOf: [ + { + type: 'number', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Base Price' + }, + currency: { + type: 'string', + maxLength: 3, + title: 'Currency', + default: 'USD' + }, + race_metadata: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Race Metadata' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + }, + organizer_id: { + type: 'string', + format: 'uuid', + title: 'Organizer Id' + }, + categories: { + items: { + '$ref': '#/components/schemas/RaceCategoryPublic' + }, + type: 'array', + title: 'Categories', + default: [] + }, + registration_count: { + type: 'integer', + title: 'Registration Count', + default: 0 + } + }, + type: 'object', + required: ['name', 'event_start_date', 'location', 'id', 'created_at', 'updated_at', 'organizer_id'], + title: 'RacePublicWithDetails' +} as const; + +export const RaceRegistrationCreateSchema = { + properties: { + bib_number: { + anyOf: [ + { + type: 'string', + maxLength: 50 + }, + { + type: 'null' + } + ], + title: 'Bib Number' + }, + emergency_contact: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Emergency Contact' + }, + emergency_phone: { + anyOf: [ + { + type: 'string', + maxLength: 50 + }, + { + type: 'null' + } + ], + title: 'Emergency Phone' + }, + tshirt_size: { + anyOf: [ + { + type: 'string', + maxLength: 10 + }, + { + type: 'null' + } + ], + title: 'Tshirt Size' + }, + special_requirements: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Special Requirements' + }, + registration_status: { + '$ref': '#/components/schemas/RegistrationStatusEnum', + default: 'pending' + }, + payment_status: { + '$ref': '#/components/schemas/PaymentStatusEnum', + default: 'unpaid' + }, + amount_paid: { + anyOf: [ + { + type: 'number', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Amount Paid' + }, + payment_reference: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Payment Reference' + }, + registration_data: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Registration Data' + }, + race_id: { + type: 'string', + format: 'uuid', + title: 'Race Id' + }, + category_id: { + type: 'string', + format: 'uuid', + title: 'Category Id' + } + }, + type: 'object', + required: ['race_id', 'category_id'], + title: 'RaceRegistrationCreate' +} as const; + +export const RaceRegistrationPublicSchema = { + properties: { + bib_number: { + anyOf: [ + { + type: 'string', + maxLength: 50 + }, + { + type: 'null' + } + ], + title: 'Bib Number' + }, + emergency_contact: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Emergency Contact' + }, + emergency_phone: { + anyOf: [ + { + type: 'string', + maxLength: 50 + }, + { + type: 'null' + } + ], + title: 'Emergency Phone' + }, + tshirt_size: { + anyOf: [ + { + type: 'string', + maxLength: 10 + }, + { + type: 'null' + } + ], + title: 'Tshirt Size' + }, + special_requirements: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Special Requirements' + }, + registration_status: { + '$ref': '#/components/schemas/RegistrationStatusEnum', + default: 'pending' + }, + payment_status: { + '$ref': '#/components/schemas/PaymentStatusEnum', + default: 'unpaid' + }, + amount_paid: { + anyOf: [ + { + type: 'number', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Amount Paid' + }, + payment_reference: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Payment Reference' + }, + registration_data: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Registration Data' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + race_id: { + type: 'string', + format: 'uuid', + title: 'Race Id' + }, + category_id: { + type: 'string', + format: 'uuid', + title: 'Category Id' + }, + runner_id: { + type: 'string', + format: 'uuid', + title: 'Runner Id' + }, + registered_at: { + type: 'string', + format: 'date-time', + title: 'Registered At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + } + }, + type: 'object', + required: ['id', 'race_id', 'category_id', 'runner_id', 'registered_at', 'updated_at'], + title: 'RaceRegistrationPublic' +} as const; + +export const RaceRegistrationPublicWithDetailsSchema = { + properties: { + bib_number: { + anyOf: [ + { + type: 'string', + maxLength: 50 + }, + { + type: 'null' + } + ], + title: 'Bib Number' + }, + emergency_contact: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Emergency Contact' + }, + emergency_phone: { + anyOf: [ + { + type: 'string', + maxLength: 50 + }, + { + type: 'null' + } + ], + title: 'Emergency Phone' + }, + tshirt_size: { + anyOf: [ + { + type: 'string', + maxLength: 10 + }, + { + type: 'null' + } + ], + title: 'Tshirt Size' + }, + special_requirements: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Special Requirements' + }, + registration_status: { + '$ref': '#/components/schemas/RegistrationStatusEnum', + default: 'pending' + }, + payment_status: { + '$ref': '#/components/schemas/PaymentStatusEnum', + default: 'unpaid' + }, + amount_paid: { + anyOf: [ + { + type: 'number', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Amount Paid' + }, + payment_reference: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Payment Reference' + }, + registration_data: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Registration Data' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + race_id: { + type: 'string', + format: 'uuid', + title: 'Race Id' + }, + category_id: { + type: 'string', + format: 'uuid', + title: 'Category Id' + }, + runner_id: { + type: 'string', + format: 'uuid', + title: 'Runner Id' + }, + registered_at: { + type: 'string', + format: 'date-time', + title: 'Registered At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + }, + runner: { + '$ref': '#/components/schemas/UserPublic' + }, + category: { + '$ref': '#/components/schemas/RaceCategoryPublic' + } + }, + type: 'object', + required: ['id', 'race_id', 'category_id', 'runner_id', 'registered_at', 'updated_at', 'runner', 'category'], + title: 'RaceRegistrationPublicWithDetails' +} as const; + +export const RaceRegistrationUpdateSchema = { + properties: { + bib_number: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Bib Number' + }, + emergency_contact: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Emergency Contact' + }, + emergency_phone: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Emergency Phone' + }, + tshirt_size: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Tshirt Size' + }, + special_requirements: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Special Requirements' + }, + registration_status: { + anyOf: [ + { + '$ref': '#/components/schemas/RegistrationStatusEnum' + }, + { + type: 'null' + } + ] + }, + payment_status: { + anyOf: [ + { + '$ref': '#/components/schemas/PaymentStatusEnum' + }, + { + type: 'null' + } + ] + }, + amount_paid: { + anyOf: [ + { + type: 'number' + }, + { + type: 'null' + } + ], + title: 'Amount Paid' + }, + payment_reference: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Payment Reference' + }, + registration_data: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Registration Data' + } + }, + type: 'object', + title: 'RaceRegistrationUpdate' +} as const; + +export const RaceRegistrationsPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/RaceRegistrationPublic' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'RaceRegistrationsPublic' +} as const; + +export const RaceResultCreateSchema = { + properties: { + finish_time_seconds: { + anyOf: [ + { + type: 'integer', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Finish Time Seconds' + }, + overall_position: { + anyOf: [ + { + type: 'integer', + minimum: 1 + }, + { + type: 'null' + } + ], + title: 'Overall Position' + }, + category_position: { + anyOf: [ + { + type: 'integer', + minimum: 1 + }, + { + type: 'null' + } + ], + title: 'Category Position' + }, + gender_position: { + anyOf: [ + { + type: 'integer', + minimum: 1 + }, + { + type: 'null' + } + ], + title: 'Gender Position' + }, + status: { + '$ref': '#/components/schemas/ResultStatusEnum', + default: 'finished' + }, + pace_per_km_seconds: { + anyOf: [ + { + type: 'number' + }, + { + type: 'null' + } + ], + title: 'Pace Per Km Seconds' + }, + notes: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Notes' + }, + registration_id: { + type: 'string', + format: 'uuid', + title: 'Registration Id' + } + }, + type: 'object', + required: ['registration_id'], + title: 'RaceResultCreate' +} as const; + +export const RaceResultPublicSchema = { + properties: { + finish_time_seconds: { + anyOf: [ + { + type: 'integer', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Finish Time Seconds' + }, + overall_position: { + anyOf: [ + { + type: 'integer', + minimum: 1 + }, + { + type: 'null' + } + ], + title: 'Overall Position' + }, + category_position: { + anyOf: [ + { + type: 'integer', + minimum: 1 + }, + { + type: 'null' + } + ], + title: 'Category Position' + }, + gender_position: { + anyOf: [ + { + type: 'integer', + minimum: 1 + }, + { + type: 'null' + } + ], + title: 'Gender Position' + }, + status: { + '$ref': '#/components/schemas/ResultStatusEnum', + default: 'finished' + }, + pace_per_km_seconds: { + anyOf: [ + { + type: 'number' + }, + { + type: 'null' + } + ], + title: 'Pace Per Km Seconds' + }, + notes: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Notes' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + registration_id: { + type: 'string', + format: 'uuid', + title: 'Registration Id' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + } + }, + type: 'object', + required: ['id', 'registration_id', 'created_at', 'updated_at'], + title: 'RaceResultPublic' +} as const; + +export const RaceResultUpdateSchema = { + properties: { + finish_time_seconds: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Finish Time Seconds' + }, + overall_position: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Overall Position' + }, + category_position: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Category Position' + }, + gender_position: { + anyOf: [ + { + type: 'integer' + }, + { + type: 'null' + } + ], + title: 'Gender Position' + }, + status: { + anyOf: [ + { + '$ref': '#/components/schemas/ResultStatusEnum' + }, + { + type: 'null' + } + ] + }, + pace_per_km_seconds: { + anyOf: [ + { + type: 'number' + }, + { + type: 'null' + } + ], + title: 'Pace Per Km Seconds' + }, + notes: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Notes' + } + }, + type: 'object', + title: 'RaceResultUpdate' +} as const; + +export const RaceResultsPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/RaceResultPublic' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'RaceResultsPublic' +} as const; + +export const RaceStatusEnumSchema = { + type: 'string', + enum: ['draft', 'published', 'registration_open', 'registration_closed', 'completed', 'cancelled'], + title: 'RaceStatusEnum' +} as const; + +export const RaceUpdateSchema = { + properties: { + name: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Name' + }, + description: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Description' + }, + event_start_date: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Event Start Date' + }, + event_end_date: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Event End Date' + }, + location: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Location' + }, + city: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'City' + }, + state: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'State' + }, + country: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Country' + }, + registration_start: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration Start' + }, + registration_end: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Registration End' + }, + status: { + anyOf: [ + { + '$ref': '#/components/schemas/RaceStatusEnum' + }, + { + type: 'null' + } + ] + }, + is_active: { + anyOf: [ + { + type: 'boolean' + }, + { + type: 'null' + } + ], + title: 'Is Active' + }, + base_price: { + anyOf: [ + { + type: 'number' + }, + { + type: 'null' + } + ], + title: 'Base Price' + }, + currency: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Currency' + }, + race_metadata: { + anyOf: [ + { + additionalProperties: true, + type: 'object' + }, + { + type: 'null' + } + ], + title: 'Race Metadata' + } + }, + type: 'object', + title: 'RaceUpdate' +} as const; + +export const RacesPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/RacePublic' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'RacesPublic' +} as const; + +export const RegistrationStatusEnumSchema = { + type: 'string', + enum: ['pending', 'confirmed', 'cancelled', 'waitlist'], + title: 'RegistrationStatusEnum' +} as const; + +export const ResultStatusEnumSchema = { + type: 'string', + enum: ['finished', 'dnf', 'dns', 'dq'], + title: 'ResultStatusEnum' +} as const; + export const RoleCreateSchema = { properties: { name: { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 75726ecfea..b05fbb6397 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, RolesReadRolesData, RolesReadRolesResponse, RolesCreateRoleData, RolesCreateRoleResponse, RolesReadRoleData, RolesReadRoleResponse, RolesUpdateRoleData, RolesUpdateRoleResponse, RolesDeleteRoleData, RolesDeleteRoleResponse, RolesAssignRoleToUserData, RolesAssignRoleToUserResponse, RolesRemoveRoleFromUserData, RolesRemoveRoleFromUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen'; +import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, RaceAttributesReadRaceAttributesData, RaceAttributesReadRaceAttributesResponse, RaceAttributesCreateRaceAttributeData, RaceAttributesCreateRaceAttributeResponse, RaceAttributesReadRaceAttributeData, RaceAttributesReadRaceAttributeResponse, RaceAttributesUpdateRaceAttributeData, RaceAttributesUpdateRaceAttributeResponse, RaceAttributesDeleteRaceAttributeData, RaceAttributesDeleteRaceAttributeResponse, RaceCategoriesReadRaceCategoriesData, RaceCategoriesReadRaceCategoriesResponse, RaceCategoriesCreateRaceCategoryData, RaceCategoriesCreateRaceCategoryResponse, RaceCategoriesReadRaceCategoryData, RaceCategoriesReadRaceCategoryResponse, RaceCategoriesUpdateRaceCategoryData, RaceCategoriesUpdateRaceCategoryResponse, RaceCategoriesDeleteRaceCategoryData, RaceCategoriesDeleteRaceCategoryResponse, RaceRegistrationsReadRaceRegistrationsData, RaceRegistrationsReadRaceRegistrationsResponse, RaceRegistrationsCreateRaceRegistrationData, RaceRegistrationsCreateRaceRegistrationResponse, RaceRegistrationsReadMyRegistrationsData, RaceRegistrationsReadMyRegistrationsResponse, RaceRegistrationsReadRaceRegistrationData, RaceRegistrationsReadRaceRegistrationResponse, RaceRegistrationsUpdateRaceRegistrationData, RaceRegistrationsUpdateRaceRegistrationResponse, RaceRegistrationsDeleteRaceRegistrationData, RaceRegistrationsDeleteRaceRegistrationResponse, RaceResultsReadRaceResultsData, RaceResultsReadRaceResultsResponse, RaceResultsCreateRaceResultData, RaceResultsCreateRaceResultResponse, RaceResultsReadRaceResultData, RaceResultsReadRaceResultResponse, RaceResultsUpdateRaceResultData, RaceResultsUpdateRaceResultResponse, RaceResultsDeleteRaceResultData, RaceResultsDeleteRaceResultResponse, RaceResultsReadRaceResultByRegistrationData, RaceResultsReadRaceResultByRegistrationResponse, RacesReadRacesData, RacesReadRacesResponse, RacesCreateRaceData, RacesCreateRaceResponse, RacesReadRaceData, RacesReadRaceResponse, RacesUpdateRaceData, RacesUpdateRaceResponse, RacesDeleteRaceData, RacesDeleteRaceResponse, RacesReadMyOrganizedRacesData, RacesReadMyOrganizedRacesResponse, RolesReadRolesData, RolesReadRolesResponse, RolesCreateRoleData, RolesCreateRoleResponse, RolesReadRoleData, RolesReadRoleResponse, RolesUpdateRoleData, RolesUpdateRoleResponse, RolesDeleteRoleData, RolesDeleteRoleResponse, RolesAssignRoleToUserData, RolesAssignRoleToUserResponse, RolesRemoveRoleFromUserData, RolesRemoveRoleFromUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen'; export class ItemsService { /** @@ -235,6 +235,663 @@ export class PrivateService { } } +export class RaceAttributesService { + /** + * Read Race Attributes + * Retrieve race attributes for a specific race. + * Public endpoint - filters by is_public by default unless authenticated organizer/admin. + * @param data The data for the request. + * @param data.raceId + * @param data.isPublic + * @returns RaceAttributesPublic Successful Response + * @throws ApiError + */ + public static readRaceAttributes(data: RaceAttributesReadRaceAttributesData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/race-attributes/', + query: { + race_id: data.raceId, + is_public: data.isPublic + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Create Race Attribute + * Create new race attribute. + * Only the race organizer or admin can create attributes. + * @param data The data for the request. + * @param data.requestBody + * @returns RaceAttributePublic Successful Response + * @throws ApiError + */ + public static createRaceAttribute(data: RaceAttributesCreateRaceAttributeData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/race-attributes/', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Race Attribute + * Get race attribute by ID. + * @param data The data for the request. + * @param data.attributeId + * @returns RaceAttributePublic Successful Response + * @throws ApiError + */ + public static readRaceAttribute(data: RaceAttributesReadRaceAttributeData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/race-attributes/{attribute_id}', + path: { + attribute_id: data.attributeId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update Race Attribute + * Update a race attribute. + * Only the race organizer or admin can update. + * @param data The data for the request. + * @param data.attributeId + * @param data.requestBody + * @returns RaceAttributePublic Successful Response + * @throws ApiError + */ + public static updateRaceAttribute(data: RaceAttributesUpdateRaceAttributeData): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/race-attributes/{attribute_id}', + path: { + attribute_id: data.attributeId + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Delete Race Attribute + * Delete a race attribute. + * Only the race organizer or admin can delete. + * @param data The data for the request. + * @param data.attributeId + * @returns Message Successful Response + * @throws ApiError + */ + public static deleteRaceAttribute(data: RaceAttributesDeleteRaceAttributeData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/race-attributes/{attribute_id}', + path: { + attribute_id: data.attributeId + }, + errors: { + 422: 'Validation Error' + } + }); + } +} + +export class RaceCategoriesService { + /** + * Read Race Categories + * Retrieve race categories for a specific race. Public endpoint. + * @param data The data for the request. + * @param data.raceId + * @param data.skip + * @param data.limit + * @returns RaceCategoriesPublic Successful Response + * @throws ApiError + */ + public static readRaceCategories(data: RaceCategoriesReadRaceCategoriesData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/race-categories/', + query: { + race_id: data.raceId, + skip: data.skip, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Create Race Category + * Create new race category. + * Only the race organizer or admin can create categories. + * @param data The data for the request. + * @param data.requestBody + * @returns RaceCategoryPublic Successful Response + * @throws ApiError + */ + public static createRaceCategory(data: RaceCategoriesCreateRaceCategoryData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/race-categories/', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Race Category + * Get race category by ID with details. Public endpoint. + * @param data The data for the request. + * @param data.categoryId + * @returns RaceCategoryPublicWithDetails Successful Response + * @throws ApiError + */ + public static readRaceCategory(data: RaceCategoriesReadRaceCategoryData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/race-categories/{category_id}', + path: { + category_id: data.categoryId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update Race Category + * Update a race category. + * Only the race organizer or admin can update. + * @param data The data for the request. + * @param data.categoryId + * @param data.requestBody + * @returns RaceCategoryPublic Successful Response + * @throws ApiError + */ + public static updateRaceCategory(data: RaceCategoriesUpdateRaceCategoryData): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/race-categories/{category_id}', + path: { + category_id: data.categoryId + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Delete Race Category + * Delete a race category. + * Only the race organizer or admin can delete. + * @param data The data for the request. + * @param data.categoryId + * @returns Message Successful Response + * @throws ApiError + */ + public static deleteRaceCategory(data: RaceCategoriesDeleteRaceCategoryData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/race-categories/{category_id}', + path: { + category_id: data.categoryId + }, + errors: { + 422: 'Validation Error' + } + }); + } +} + +export class RaceRegistrationsService { + /** + * Read Race Registrations + * Retrieve race registrations. + * - Runners see only their own registrations + * - Organizers see registrations for their races + * - Admins see all registrations + * @param data The data for the request. + * @param data.raceId + * @param data.categoryId + * @param data.skip + * @param data.limit + * @returns RaceRegistrationsPublic Successful Response + * @throws ApiError + */ + public static readRaceRegistrations(data: RaceRegistrationsReadRaceRegistrationsData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/race-registrations/', + query: { + race_id: data.raceId, + category_id: data.categoryId, + skip: data.skip, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Create Race Registration + * Create new race registration. + * Runners can register themselves for races. + * @param data The data for the request. + * @param data.requestBody + * @returns RaceRegistrationPublic Successful Response + * @throws ApiError + */ + public static createRaceRegistration(data: RaceRegistrationsCreateRaceRegistrationData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/race-registrations/', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read My Registrations + * Retrieve current user's race registrations. + * @param data The data for the request. + * @param data.skip + * @param data.limit + * @returns RaceRegistrationsPublic Successful Response + * @throws ApiError + */ + public static readMyRegistrations(data: RaceRegistrationsReadMyRegistrationsData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/race-registrations/my', + query: { + skip: data.skip, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Race Registration + * Get race registration by ID with details. + * @param data The data for the request. + * @param data.registrationId + * @returns RaceRegistrationPublicWithDetails Successful Response + * @throws ApiError + */ + public static readRaceRegistration(data: RaceRegistrationsReadRaceRegistrationData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/race-registrations/{registration_id}', + path: { + registration_id: data.registrationId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update Race Registration + * Update a race registration. + * - Runners can update their own registration details + * - Organizers can update any field for their races + * - Admins can update anything + * @param data The data for the request. + * @param data.registrationId + * @param data.requestBody + * @returns RaceRegistrationPublic Successful Response + * @throws ApiError + */ + public static updateRaceRegistration(data: RaceRegistrationsUpdateRaceRegistrationData): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/race-registrations/{registration_id}', + path: { + registration_id: data.registrationId + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Delete Race Registration + * Delete/Cancel a race registration. + * - Runners can cancel their own registrations + * - Organizers can cancel registrations for their races + * - Admins can cancel any registration + * @param data The data for the request. + * @param data.registrationId + * @returns Message Successful Response + * @throws ApiError + */ + public static deleteRaceRegistration(data: RaceRegistrationsDeleteRaceRegistrationData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/race-registrations/{registration_id}', + path: { + registration_id: data.registrationId + }, + errors: { + 422: 'Validation Error' + } + }); + } +} + +export class RaceResultsService { + /** + * Read Race Results + * Retrieve race results for a specific race. Public endpoint. + * @param data The data for the request. + * @param data.raceId + * @param data.skip + * @param data.limit + * @returns RaceResultsPublic Successful Response + * @throws ApiError + */ + public static readRaceResults(data: RaceResultsReadRaceResultsData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/race-results/', + query: { + race_id: data.raceId, + skip: data.skip, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Create Race Result + * Create new race result. + * Only race organizers, volunteers, or admins can create results. + * @param data The data for the request. + * @param data.requestBody + * @returns RaceResultPublic Successful Response + * @throws ApiError + */ + public static createRaceResult(data: RaceResultsCreateRaceResultData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/race-results/', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Race Result + * Get race result by ID. Public endpoint. + * @param data The data for the request. + * @param data.resultId + * @returns RaceResultPublic Successful Response + * @throws ApiError + */ + public static readRaceResult(data: RaceResultsReadRaceResultData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/race-results/{result_id}', + path: { + result_id: data.resultId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update Race Result + * Update a race result. + * Only race organizers, volunteers, or admins can update results. + * @param data The data for the request. + * @param data.resultId + * @param data.requestBody + * @returns RaceResultPublic Successful Response + * @throws ApiError + */ + public static updateRaceResult(data: RaceResultsUpdateRaceResultData): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/race-results/{result_id}', + path: { + result_id: data.resultId + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Delete Race Result + * Delete a race result. + * Only race organizers or admins can delete results. + * @param data The data for the request. + * @param data.resultId + * @returns Message Successful Response + * @throws ApiError + */ + public static deleteRaceResult(data: RaceResultsDeleteRaceResultData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/race-results/{result_id}', + path: { + result_id: data.resultId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Race Result By Registration + * Get race result by registration ID. Public endpoint. + * @param data The data for the request. + * @param data.registrationId + * @returns RaceResultPublic Successful Response + * @throws ApiError + */ + public static readRaceResultByRegistration(data: RaceResultsReadRaceResultByRegistrationData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/race-results/registration/{registration_id}', + path: { + registration_id: data.registrationId + }, + errors: { + 422: 'Validation Error' + } + }); + } +} + +export class RacesService { + /** + * Read Races + * Retrieve races. Public endpoint - anyone can view races. + * Optionally filter by organizer_id. + * @param data The data for the request. + * @param data.skip + * @param data.limit + * @param data.organizerId + * @returns RacesPublic Successful Response + * @throws ApiError + */ + public static readRaces(data: RacesReadRacesData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/races/', + query: { + skip: data.skip, + limit: data.limit, + organizer_id: data.organizerId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Create Race + * Create new race. + * Requires organizer or admin role. + * @param data The data for the request. + * @param data.requestBody + * @returns RacePublic Successful Response + * @throws ApiError + */ + public static createRace(data: RacesCreateRaceData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/races/', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Race + * Get race by ID with details. Public endpoint. + * @param data The data for the request. + * @param data.raceId + * @returns RacePublicWithDetails Successful Response + * @throws ApiError + */ + public static readRace(data: RacesReadRaceData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/races/{race_id}', + path: { + race_id: data.raceId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update Race + * Update a race. + * Only the organizer or admin can update. + * @param data The data for the request. + * @param data.raceId + * @param data.requestBody + * @returns RacePublic Successful Response + * @throws ApiError + */ + public static updateRace(data: RacesUpdateRaceData): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/races/{race_id}', + path: { + race_id: data.raceId + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Delete Race + * Delete a race. + * Only the organizer or admin can delete. + * @param data The data for the request. + * @param data.raceId + * @returns Message Successful Response + * @throws ApiError + */ + public static deleteRace(data: RacesDeleteRaceData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/races/{race_id}', + path: { + race_id: data.raceId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read My Organized Races + * Retrieve races organized by the current user. + * Requires organizer or admin role. + * @param data The data for the request. + * @param data.skip + * @param data.limit + * @returns RacesPublic Successful Response + * @throws ApiError + */ + public static readMyOrganizedRaces(data: RacesReadMyOrganizedRacesData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/races/my/organized', + query: { + skip: data.skip, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } +} + export class RolesService { /** * Read Roles diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index f4b220d65e..a9b959a253 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -1,5 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts +export type AttributeTypeEnum = 'string' | 'text' | 'url' | 'date' | 'datetime' | 'number' | 'boolean' | 'email' | 'phone'; + export type Body_login_login_access_token = { grant_type?: (string | null); username: string; @@ -45,6 +47,8 @@ export type NewPassword = { new_password: string; }; +export type PaymentStatusEnum = 'unpaid' | 'paid' | 'refunded' | 'partial'; + export type PrivateUserCreate = { email: string; password: string; @@ -52,6 +56,372 @@ export type PrivateUserCreate = { is_verified?: boolean; }; +export type RaceAttributeCreate = { + key: string; + value_text?: (string | null); + attribute_type?: AttributeTypeEnum; + label?: (string | null); + description?: (string | null); + is_required?: boolean; + is_public?: boolean; + display_order?: number; + race_id: string; +}; + +export type RaceAttributePublic = { + key: string; + value_text?: (string | null); + attribute_type?: AttributeTypeEnum; + label?: (string | null); + description?: (string | null); + is_required?: boolean; + is_public?: boolean; + display_order?: number; + id: string; + race_id: string; + created_at: string; + updated_at: string; +}; + +export type RaceAttributesPublic = { + data: Array; + count: number; +}; + +export type RaceAttributeUpdate = { + value_text?: (string | null); + attribute_type?: (AttributeTypeEnum | null); + label?: (string | null); + description?: (string | null); + is_required?: (boolean | null); + is_public?: (boolean | null); + display_order?: (number | null); +}; + +export type RaceCategoriesPublic = { + data: Array; + count: number; +}; + +export type RaceCategoryCreate = { + name: string; + distance_km: number; + distance_unit?: string; + start_time: string; + end_time?: (string | null); + cutoff_time_minutes?: (number | null); + registration_start?: (string | null); + registration_end?: (string | null); + price?: (number | null); + early_bird_price?: (number | null); + early_bird_deadline?: (string | null); + max_participants?: (number | null); + min_age?: (number | null); + max_age?: (number | null); + gender_restriction?: (string | null); + description?: (string | null); + display_order?: number; + is_active?: boolean; + race_id: string; +}; + +export type RaceCategoryPublic = { + name: string; + distance_km: number; + distance_unit?: string; + start_time: string; + end_time?: (string | null); + cutoff_time_minutes?: (number | null); + registration_start?: (string | null); + registration_end?: (string | null); + price?: (number | null); + early_bird_price?: (number | null); + early_bird_deadline?: (string | null); + max_participants?: (number | null); + min_age?: (number | null); + max_age?: (number | null); + gender_restriction?: (string | null); + description?: (string | null); + display_order?: number; + is_active?: boolean; + id: string; + race_id: string; + created_at: string; + updated_at: string; +}; + +export type RaceCategoryPublicWithDetails = { + name: string; + distance_km: number; + distance_unit?: string; + start_time: string; + end_time?: (string | null); + cutoff_time_minutes?: (number | null); + registration_start?: (string | null); + registration_end?: (string | null); + price?: (number | null); + early_bird_price?: (number | null); + early_bird_deadline?: (string | null); + max_participants?: (number | null); + min_age?: (number | null); + max_age?: (number | null); + gender_restriction?: (string | null); + description?: (string | null); + display_order?: number; + is_active?: boolean; + id: string; + race_id: string; + created_at: string; + updated_at: string; + registration_count?: number; + available_spots?: (number | null); + is_registration_open?: boolean; + current_price?: (number | null); +}; + +export type RaceCategoryUpdate = { + name?: (string | null); + distance_km?: (number | null); + distance_unit?: (string | null); + start_time?: (string | null); + end_time?: (string | null); + cutoff_time_minutes?: (number | null); + registration_start?: (string | null); + registration_end?: (string | null); + price?: (number | null); + early_bird_price?: (number | null); + early_bird_deadline?: (string | null); + max_participants?: (number | null); + min_age?: (number | null); + max_age?: (number | null); + gender_restriction?: (string | null); + description?: (string | null); + display_order?: (number | null); + is_active?: (boolean | null); +}; + +export type RaceCreate = { + name: string; + description?: (string | null); + event_start_date: string; + event_end_date?: (string | null); + location: string; + city?: (string | null); + state?: (string | null); + country?: string; + registration_start?: (string | null); + registration_end?: (string | null); + status?: RaceStatusEnum; + is_active?: boolean; + base_price?: (number | null); + currency?: string; + race_metadata?: ({ + [key: string]: unknown; +} | null); +}; + +export type RacePublic = { + name: string; + description?: (string | null); + event_start_date: string; + event_end_date?: (string | null); + location: string; + city?: (string | null); + state?: (string | null); + country?: string; + registration_start?: (string | null); + registration_end?: (string | null); + status?: RaceStatusEnum; + is_active?: boolean; + base_price?: (number | null); + currency?: string; + race_metadata?: ({ + [key: string]: unknown; +} | null); + id: string; + created_at: string; + updated_at: string; + organizer_id: string; +}; + +export type RacePublicWithDetails = { + name: string; + description?: (string | null); + event_start_date: string; + event_end_date?: (string | null); + location: string; + city?: (string | null); + state?: (string | null); + country?: string; + registration_start?: (string | null); + registration_end?: (string | null); + status?: RaceStatusEnum; + is_active?: boolean; + base_price?: (number | null); + currency?: string; + race_metadata?: ({ + [key: string]: unknown; +} | null); + id: string; + created_at: string; + updated_at: string; + organizer_id: string; + categories?: Array; + registration_count?: number; +}; + +export type RaceRegistrationCreate = { + bib_number?: (string | null); + emergency_contact?: (string | null); + emergency_phone?: (string | null); + tshirt_size?: (string | null); + special_requirements?: (string | null); + registration_status?: RegistrationStatusEnum; + payment_status?: PaymentStatusEnum; + amount_paid?: (number | null); + payment_reference?: (string | null); + registration_data?: ({ + [key: string]: unknown; +} | null); + race_id: string; + category_id: string; +}; + +export type RaceRegistrationPublic = { + bib_number?: (string | null); + emergency_contact?: (string | null); + emergency_phone?: (string | null); + tshirt_size?: (string | null); + special_requirements?: (string | null); + registration_status?: RegistrationStatusEnum; + payment_status?: PaymentStatusEnum; + amount_paid?: (number | null); + payment_reference?: (string | null); + registration_data?: ({ + [key: string]: unknown; +} | null); + id: string; + race_id: string; + category_id: string; + runner_id: string; + registered_at: string; + updated_at: string; +}; + +export type RaceRegistrationPublicWithDetails = { + bib_number?: (string | null); + emergency_contact?: (string | null); + emergency_phone?: (string | null); + tshirt_size?: (string | null); + special_requirements?: (string | null); + registration_status?: RegistrationStatusEnum; + payment_status?: PaymentStatusEnum; + amount_paid?: (number | null); + payment_reference?: (string | null); + registration_data?: ({ + [key: string]: unknown; +} | null); + id: string; + race_id: string; + category_id: string; + runner_id: string; + registered_at: string; + updated_at: string; + runner: UserPublic; + category: RaceCategoryPublic; +}; + +export type RaceRegistrationsPublic = { + data: Array; + count: number; +}; + +export type RaceRegistrationUpdate = { + bib_number?: (string | null); + emergency_contact?: (string | null); + emergency_phone?: (string | null); + tshirt_size?: (string | null); + special_requirements?: (string | null); + registration_status?: (RegistrationStatusEnum | null); + payment_status?: (PaymentStatusEnum | null); + amount_paid?: (number | null); + payment_reference?: (string | null); + registration_data?: ({ + [key: string]: unknown; +} | null); +}; + +export type RaceResultCreate = { + finish_time_seconds?: (number | null); + overall_position?: (number | null); + category_position?: (number | null); + gender_position?: (number | null); + status?: ResultStatusEnum; + pace_per_km_seconds?: (number | null); + notes?: (string | null); + registration_id: string; +}; + +export type RaceResultPublic = { + finish_time_seconds?: (number | null); + overall_position?: (number | null); + category_position?: (number | null); + gender_position?: (number | null); + status?: ResultStatusEnum; + pace_per_km_seconds?: (number | null); + notes?: (string | null); + id: string; + registration_id: string; + created_at: string; + updated_at: string; +}; + +export type RaceResultsPublic = { + data: Array; + count: number; +}; + +export type RaceResultUpdate = { + finish_time_seconds?: (number | null); + overall_position?: (number | null); + category_position?: (number | null); + gender_position?: (number | null); + status?: (ResultStatusEnum | null); + pace_per_km_seconds?: (number | null); + notes?: (string | null); +}; + +export type RacesPublic = { + data: Array; + count: number; +}; + +export type RaceStatusEnum = 'draft' | 'published' | 'registration_open' | 'registration_closed' | 'completed' | 'cancelled'; + +export type RaceUpdate = { + name?: (string | null); + description?: (string | null); + event_start_date?: (string | null); + event_end_date?: (string | null); + location?: (string | null); + city?: (string | null); + state?: (string | null); + country?: (string | null); + registration_start?: (string | null); + registration_end?: (string | null); + status?: (RaceStatusEnum | null); + is_active?: (boolean | null); + base_price?: (number | null); + currency?: (string | null); + race_metadata?: ({ + [key: string]: unknown; +} | null); +}; + +export type RegistrationStatusEnum = 'pending' | 'confirmed' | 'cancelled' | 'waitlist'; + +export type ResultStatusEnum = 'finished' | 'dnf' | 'dns' | 'dq'; + export type RoleCreate = { name: string; description?: (string | null); @@ -200,6 +570,191 @@ export type PrivateCreateUserData = { export type PrivateCreateUserResponse = (UserPublic); +export type RaceAttributesReadRaceAttributesData = { + isPublic?: (boolean | null); + raceId: string; +}; + +export type RaceAttributesReadRaceAttributesResponse = (RaceAttributesPublic); + +export type RaceAttributesCreateRaceAttributeData = { + requestBody: RaceAttributeCreate; +}; + +export type RaceAttributesCreateRaceAttributeResponse = (RaceAttributePublic); + +export type RaceAttributesReadRaceAttributeData = { + attributeId: string; +}; + +export type RaceAttributesReadRaceAttributeResponse = (RaceAttributePublic); + +export type RaceAttributesUpdateRaceAttributeData = { + attributeId: string; + requestBody: RaceAttributeUpdate; +}; + +export type RaceAttributesUpdateRaceAttributeResponse = (RaceAttributePublic); + +export type RaceAttributesDeleteRaceAttributeData = { + attributeId: string; +}; + +export type RaceAttributesDeleteRaceAttributeResponse = (Message); + +export type RaceCategoriesReadRaceCategoriesData = { + limit?: number; + raceId: string; + skip?: number; +}; + +export type RaceCategoriesReadRaceCategoriesResponse = (RaceCategoriesPublic); + +export type RaceCategoriesCreateRaceCategoryData = { + requestBody: RaceCategoryCreate; +}; + +export type RaceCategoriesCreateRaceCategoryResponse = (RaceCategoryPublic); + +export type RaceCategoriesReadRaceCategoryData = { + categoryId: string; +}; + +export type RaceCategoriesReadRaceCategoryResponse = (RaceCategoryPublicWithDetails); + +export type RaceCategoriesUpdateRaceCategoryData = { + categoryId: string; + requestBody: RaceCategoryUpdate; +}; + +export type RaceCategoriesUpdateRaceCategoryResponse = (RaceCategoryPublic); + +export type RaceCategoriesDeleteRaceCategoryData = { + categoryId: string; +}; + +export type RaceCategoriesDeleteRaceCategoryResponse = (Message); + +export type RaceRegistrationsReadRaceRegistrationsData = { + categoryId?: (string | null); + limit?: number; + raceId?: (string | null); + skip?: number; +}; + +export type RaceRegistrationsReadRaceRegistrationsResponse = (RaceRegistrationsPublic); + +export type RaceRegistrationsCreateRaceRegistrationData = { + requestBody: RaceRegistrationCreate; +}; + +export type RaceRegistrationsCreateRaceRegistrationResponse = (RaceRegistrationPublic); + +export type RaceRegistrationsReadMyRegistrationsData = { + limit?: number; + skip?: number; +}; + +export type RaceRegistrationsReadMyRegistrationsResponse = (RaceRegistrationsPublic); + +export type RaceRegistrationsReadRaceRegistrationData = { + registrationId: string; +}; + +export type RaceRegistrationsReadRaceRegistrationResponse = (RaceRegistrationPublicWithDetails); + +export type RaceRegistrationsUpdateRaceRegistrationData = { + registrationId: string; + requestBody: RaceRegistrationUpdate; +}; + +export type RaceRegistrationsUpdateRaceRegistrationResponse = (RaceRegistrationPublic); + +export type RaceRegistrationsDeleteRaceRegistrationData = { + registrationId: string; +}; + +export type RaceRegistrationsDeleteRaceRegistrationResponse = (Message); + +export type RaceResultsReadRaceResultsData = { + limit?: number; + raceId: string; + skip?: number; +}; + +export type RaceResultsReadRaceResultsResponse = (RaceResultsPublic); + +export type RaceResultsCreateRaceResultData = { + requestBody: RaceResultCreate; +}; + +export type RaceResultsCreateRaceResultResponse = (RaceResultPublic); + +export type RaceResultsReadRaceResultData = { + resultId: string; +}; + +export type RaceResultsReadRaceResultResponse = (RaceResultPublic); + +export type RaceResultsUpdateRaceResultData = { + requestBody: RaceResultUpdate; + resultId: string; +}; + +export type RaceResultsUpdateRaceResultResponse = (RaceResultPublic); + +export type RaceResultsDeleteRaceResultData = { + resultId: string; +}; + +export type RaceResultsDeleteRaceResultResponse = (Message); + +export type RaceResultsReadRaceResultByRegistrationData = { + registrationId: string; +}; + +export type RaceResultsReadRaceResultByRegistrationResponse = (RaceResultPublic); + +export type RacesReadRacesData = { + limit?: number; + organizerId?: (string | null); + skip?: number; +}; + +export type RacesReadRacesResponse = (RacesPublic); + +export type RacesCreateRaceData = { + requestBody: RaceCreate; +}; + +export type RacesCreateRaceResponse = (RacePublic); + +export type RacesReadRaceData = { + raceId: string; +}; + +export type RacesReadRaceResponse = (RacePublicWithDetails); + +export type RacesUpdateRaceData = { + raceId: string; + requestBody: RaceUpdate; +}; + +export type RacesUpdateRaceResponse = (RacePublic); + +export type RacesDeleteRaceData = { + raceId: string; +}; + +export type RacesDeleteRaceResponse = (Message); + +export type RacesReadMyOrganizedRacesData = { + limit?: number; + skip?: number; +}; + +export type RacesReadMyOrganizedRacesResponse = (RacesPublic); + export type RolesReadRolesData = { limit?: number; skip?: number; diff --git a/frontend/src/components/Media/MediaGalleryManager.tsx b/frontend/src/components/Media/MediaGalleryManager.tsx new file mode 100644 index 0000000000..846bd20f5e --- /dev/null +++ b/frontend/src/components/Media/MediaGalleryManager.tsx @@ -0,0 +1,490 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { ArrowUpDown, ImageUp, Star, Trash2 } from "lucide-react" +import { useMemo, useState } from "react" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import useCustomToast from "@/hooks/useCustomToast" +import { + type MediaAsset, + deleteMediaAsset, + listMediaAssets, + updateMediaAsset, + uploadMediaAsset, +} from "@/lib/media-api" + +interface MediaGalleryManagerProps { + contentType: string + contentId: string + title?: string + description?: string +} + +type MediaKind = "cover" | "banner" | "gallery" + +interface UploadProgressItem { + id: string + kind: MediaKind + fileName: string + progress: number +} + +function getMediaUrl(fileUrl: string) { + if (fileUrl.startsWith("http://") || fileUrl.startsWith("https://")) { + return fileUrl + } + const base = (import.meta.env.VITE_API_URL || "").replace(/\/$/, "") + return `${base}${fileUrl}` +} + +export default function MediaGalleryManager({ + contentType, + contentId, + title = "Media Gallery", + description = "Upload and manage cover, banner, and gallery images.", +}: MediaGalleryManagerProps) { + const [draggedGalleryId, setDraggedGalleryId] = useState(null) + const [uploadProgressItems, setUploadProgressItems] = useState([]) + const [dragActiveKind, setDragActiveKind] = useState(null) + const queryClient = useQueryClient() + const { showSuccessToast, showErrorToast } = useCustomToast() + + const queryKey = ["media", contentType, contentId] + + const mediaQuery = useQuery({ + queryKey, + queryFn: () => listMediaAssets({ contentType, contentId }), + }) + + const uploadMutation = useMutation({ + mutationFn: async ({ files, kind }: { files: File[]; kind: MediaKind }) => { + const normalizedFiles = kind === "gallery" ? files : files.slice(0, 1) + + for (const [index, file] of normalizedFiles.entries()) { + const uploadId = `${file.name}-${Date.now()}-${index}` + setUploadProgressItems((prev) => [ + ...prev, + { id: uploadId, kind, fileName: file.name, progress: 0 }, + ]) + + await uploadMediaAsset({ + file, + contentType, + contentId, + kind, + isPrimary: kind !== "gallery" ? true : false, + displayOrder: 0, + onProgress: (percent) => { + setUploadProgressItems((prev) => + prev.map((item) => + item.id === uploadId ? { ...item, progress: percent } : item + ) + ) + }, + }) + + setUploadProgressItems((prev) => + prev.filter((item) => item.id !== uploadId) + ) + } + }, + onSuccess: () => { + showSuccessToast("Media uploaded successfully") + queryClient.invalidateQueries({ queryKey }) + }, + onError: (error) => { + showErrorToast(error instanceof Error ? error.message : "Upload failed") + }, + }) + + const deleteMutation = useMutation({ + mutationFn: (mediaId: string) => deleteMediaAsset(mediaId), + onSuccess: () => { + showSuccessToast("Media deleted") + queryClient.invalidateQueries({ queryKey }) + }, + onError: (error) => { + showErrorToast(error instanceof Error ? error.message : "Delete failed") + }, + }) + + const reorderMutation = useMutation({ + mutationFn: async (orderedGalleryAssets: MediaAsset[]) => { + await Promise.all( + orderedGalleryAssets.map((asset, index) => + updateMediaAsset(asset.id, { + display_order: index, + kind: "gallery", + }) + ) + ) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }) + }, + onError: (error) => { + showErrorToast(error instanceof Error ? error.message : "Reorder failed") + }, + }) + + const updateMutation = useMutation({ + mutationFn: ({ mediaId, payload }: { mediaId: string; payload: { kind?: MediaKind; is_primary?: boolean; display_order?: number } }) => + updateMediaAsset(mediaId, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey }) + }, + onError: (error) => { + showErrorToast(error instanceof Error ? error.message : "Update failed") + }, + }) + + const onFileChange = async ( + event: React.ChangeEvent, + kind: MediaKind + ) => { + const files = event.target.files + if (!files || files.length === 0) return + uploadMutation.mutate({ files: Array.from(files), kind }) + event.target.value = "" + } + + const assets = mediaQuery.data?.data ?? [] + + const { coverAsset, bannerAsset, galleryAssets } = useMemo(() => { + const cover = assets + .filter((asset) => asset.kind === "cover") + .sort((a, b) => Number(b.is_primary) - Number(a.is_primary) || a.display_order - b.display_order)[0] + + const banner = assets + .filter((asset) => asset.kind === "banner") + .sort((a, b) => Number(b.is_primary) - Number(a.is_primary) || a.display_order - b.display_order)[0] + + const gallery = assets + .filter((asset) => asset.kind === "gallery") + .sort((a, b) => a.display_order - b.display_order) + + return { + coverAsset: cover, + bannerAsset: banner, + galleryAssets: gallery, + } + }, [assets]) + + const handleDropUpload = (kind: MediaKind, event: React.DragEvent) => { + event.preventDefault() + setDragActiveKind(null) + const files = Array.from(event.dataTransfer.files || []).filter((file) => + file.type.startsWith("image/") + ) + if (files.length === 0) return + uploadMutation.mutate({ files, kind }) + } + + const onGalleryDrop = (targetId: string) => { + if (!draggedGalleryId || draggedGalleryId === targetId) return + + const sourceIndex = galleryAssets.findIndex((item) => item.id === draggedGalleryId) + const targetIndex = galleryAssets.findIndex((item) => item.id === targetId) + if (sourceIndex === -1 || targetIndex === -1) return + + const reordered = [...galleryAssets] + const [moved] = reordered.splice(sourceIndex, 1) + reordered.splice(targetIndex, 0, moved) + + reorderMutation.mutate(reordered) + setDraggedGalleryId(null) + } + + const kindUploads = (kind: MediaKind) => + uploadProgressItems.filter((item) => item.kind === kind) + + const renderUploadProgress = (kind: MediaKind) => { + const uploads = kindUploads(kind) + if (uploads.length === 0) return null + + return ( +
+ {uploads.map((item) => ( +
+
+ {item.fileName} + {item.progress}% +
+
+
+
+
+ ))} +
+ ) + } + + return ( + + + {title} + {description} + + + {mediaQuery.isLoading ? ( +

Loading media...

+ ) : ( +
+
+
+

Cover Image

+

Single primary hero image for cards and listings.

+
+
+
+ {coverAsset ? ( + {coverAsset.alt_text + ) : ( +
No cover image
+ )} +
+
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: dropzone needs drag events */} +
{ + e.preventDefault() + setDragActiveKind("cover") + }} + onDragLeave={() => setDragActiveKind(null)} + onDrop={(e) => handleDropUpload("cover", e)} + > + +

Drag and drop cover image here

+

or choose a file below

+
+ onFileChange(e, "cover")} /> + {coverAsset ? ( +
+ + +
+ ) : null} + {renderUploadProgress("cover")} +
+
+
+ + + +
+
+

Banner Image

+

Single wide banner image for detail headers.

+
+
+
+ {bannerAsset ? ( + {bannerAsset.alt_text + ) : ( +
No banner image
+ )} +
+
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: dropzone needs drag events */} +
{ + e.preventDefault() + setDragActiveKind("banner") + }} + onDragLeave={() => setDragActiveKind(null)} + onDrop={(e) => handleDropUpload("banner", e)} + > + +

Drag and drop banner image here

+

or choose a file below

+
+ onFileChange(e, "banner")} /> + {bannerAsset ? ( +
+ + +
+ ) : null} + {renderUploadProgress("banner")} +
+
+
+ + + +
+
+
+

Gallery

+

Drag items to reorder. Order is persisted automatically.

+
+ + + Drag to reorder + +
+ + {/* biome-ignore lint/a11y/noStaticElementInteractions: dropzone needs drag events */} +
{ + e.preventDefault() + setDragActiveKind("gallery") + }} + onDragLeave={() => setDragActiveKind(null)} + onDrop={(e) => handleDropUpload("gallery", e)} + > + +

Drag and drop gallery images here

+

or choose files below

+
+ onFileChange(e, "gallery")} /> + {renderUploadProgress("gallery")} + + {galleryAssets.length === 0 ? ( +

No gallery images uploaded yet.

+ ) : ( +
+ {galleryAssets.map((asset) => { + return ( +
+ +
+ + + + +
+
+ ) + })} +
+ )} +
+
+ )} +
+
+ ) +} diff --git a/frontend/src/components/Public/PublicFooter.tsx b/frontend/src/components/Public/PublicFooter.tsx index a392c2ac21..5b5817d53c 100644 --- a/frontend/src/components/Public/PublicFooter.tsx +++ b/frontend/src/components/Public/PublicFooter.tsx @@ -23,7 +23,8 @@ export function PublicFooter() {

RaceHub

- Find and register for races near you. Built with FastAPI Full-Stack Template. + Find and register for races near you. Built with FastAPI + Full-Stack Template.

diff --git a/frontend/src/components/Public/PublicHeader.tsx b/frontend/src/components/Public/PublicHeader.tsx index 7286f87768..a533209478 100644 --- a/frontend/src/components/Public/PublicHeader.tsx +++ b/frontend/src/components/Public/PublicHeader.tsx @@ -1,11 +1,7 @@ import { Link } from "@tanstack/react-router" import { Menu } from "lucide-react" import { Button } from "@/components/ui/button" -import { - Sheet, - SheetContent, - SheetTrigger, -} from "@/components/ui/sheet" +import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet" import { isLoggedIn } from "@/hooks/useAuth" const navLinks = [ @@ -22,11 +18,17 @@ export function PublicHeader() {
{/* Logo and Desktop Navigation */}
- + RaceHub - -