Compare commits
12 commits
f46d2ae8b3
...
6d0f125536
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d0f125536 | |||
| 3beb23a765 | |||
| db181b338c | |||
| 33aa8ad13b | |||
| c4594a3f73 | |||
| 04333d210b | |||
| 17aead2e21 | |||
| 4cb561d54f | |||
| 280c1e5687 | |||
| c3a501e3b2 | |||
| badb45da59 | |||
| 168b67acee |
44 changed files with 3414 additions and 1811 deletions
244
REFACTOR_PLAN.md
Normal file
244
REFACTOR_PLAN.md
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
# Refactoring Plan: Extract Business Logic from Routes
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Remove all business/domain logic from route handlers. Routes should only:
|
||||||
|
1. Receive HTTP requests
|
||||||
|
2. Call service methods
|
||||||
|
3. Map responses using mappers
|
||||||
|
4. Return HTTP responses
|
||||||
|
|
||||||
|
## Current State Analysis
|
||||||
|
|
||||||
|
### Routes with Business Logic
|
||||||
|
|
||||||
|
#### 1. `routes/auth.py`
|
||||||
|
**Business Logic:**
|
||||||
|
- `register()`: Invite validation, user creation, invite marking, role assignment
|
||||||
|
- `get_default_role()`: Database query (should use repository)
|
||||||
|
|
||||||
|
**Action:** Create `AuthService` with:
|
||||||
|
- `register_user()` - handles entire registration flow
|
||||||
|
- `login_user()` - handles authentication and token creation
|
||||||
|
|
||||||
|
#### 2. `routes/invites.py`
|
||||||
|
**Business Logic:**
|
||||||
|
- `check_invite()`: Invite validation logic
|
||||||
|
- `get_my_invites()`: Database query + response building
|
||||||
|
- `create_invite()`: Invite creation with collision retry logic
|
||||||
|
- `list_all_invites()`: Query building, filtering, pagination
|
||||||
|
- `revoke_invite()`: Revocation business logic
|
||||||
|
|
||||||
|
**Action:** Use existing `InviteService` (already exists but not fully used):
|
||||||
|
- Move `check_invite()` logic to `InviteService.check_invite_validity()`
|
||||||
|
- Move `create_invite()` logic to `InviteService.create_invite()`
|
||||||
|
- Move `revoke_invite()` logic to `InviteService.revoke_invite()`
|
||||||
|
- Add `InviteService.get_user_invites()` for `get_my_invites()`
|
||||||
|
- Add `InviteService.list_invites()` for `list_all_invites()`
|
||||||
|
|
||||||
|
#### 3. `routes/profile.py`
|
||||||
|
**Business Logic:**
|
||||||
|
- `get_godfather_email()`: Database query (should use repository)
|
||||||
|
- `get_profile()`: Data retrieval and response building
|
||||||
|
- `update_profile()`: Validation and field updates
|
||||||
|
|
||||||
|
**Action:** Create `ProfileService` with:
|
||||||
|
- `get_profile()` - retrieves profile with godfather email
|
||||||
|
- `update_profile()` - validates and updates profile fields
|
||||||
|
|
||||||
|
#### 4. `routes/availability.py`
|
||||||
|
**Business Logic:**
|
||||||
|
- `get_availability()`: Query, grouping by date, transformation
|
||||||
|
- `set_availability()`: Slot overlap validation, time ordering validation, deletion, creation
|
||||||
|
- `copy_availability()`: Source validation, copying logic, atomic transaction handling
|
||||||
|
|
||||||
|
**Action:** Create `AvailabilityService` with:
|
||||||
|
- `get_availability_for_range()` - gets and groups availability
|
||||||
|
- `set_availability_for_date()` - validates slots and replaces availability
|
||||||
|
- `copy_availability()` - copies availability from one date to others
|
||||||
|
|
||||||
|
#### 5. `routes/audit.py`
|
||||||
|
**Business Logic:**
|
||||||
|
- `get_price_history()`: Database query
|
||||||
|
- `fetch_price_now()`: Price fetching, duplicate timestamp handling
|
||||||
|
- `_to_price_history_response()`: Mapping (should use mapper)
|
||||||
|
|
||||||
|
**Action:** Create `PriceService` with:
|
||||||
|
- `get_recent_prices()` - gets recent price history
|
||||||
|
- `fetch_and_store_price()` - fetches from Bitfinex and stores (handles duplicates)
|
||||||
|
- Move `_to_price_history_response()` to `PriceHistoryMapper`
|
||||||
|
|
||||||
|
#### 6. `routes/exchange.py`
|
||||||
|
**Business Logic:**
|
||||||
|
- `get_available_slots()`: Query, slot expansion logic
|
||||||
|
- Enum validation (acceptable - this is input validation at route level)
|
||||||
|
|
||||||
|
**Action:**
|
||||||
|
- Move slot expansion logic to `ExchangeService` or `AvailabilityService`
|
||||||
|
- Keep enum validation in route (it's input validation, not business logic)
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Phase 1: Create Missing Services
|
||||||
|
1. ✅ `ExchangeService` (already exists)
|
||||||
|
2. ✅ `InviteService` (already exists, needs expansion)
|
||||||
|
3. ❌ `AuthService` (needs creation)
|
||||||
|
4. ❌ `ProfileService` (needs creation)
|
||||||
|
5. ❌ `AvailabilityService` (needs creation)
|
||||||
|
6. ❌ `PriceService` (needs creation)
|
||||||
|
|
||||||
|
### Phase 2: Expand Existing Services
|
||||||
|
1. Expand `InviteService`:
|
||||||
|
- Add `get_user_invites()`
|
||||||
|
- Add `list_invites()` with pagination
|
||||||
|
- Ensure all methods use repositories
|
||||||
|
|
||||||
|
### Phase 3: Update Routes to Use Services
|
||||||
|
1. `routes/auth.py` → Use `AuthService`
|
||||||
|
2. `routes/invites.py` → Use `InviteService` consistently
|
||||||
|
3. `routes/profile.py` → Use `ProfileService`
|
||||||
|
4. `routes/availability.py` → Use `AvailabilityService`
|
||||||
|
5. `routes/audit.py` → Use `PriceService`
|
||||||
|
6. `routes/exchange.py` → Move slot expansion to service
|
||||||
|
|
||||||
|
### Phase 4: Clean Up
|
||||||
|
1. Remove all direct database queries from routes
|
||||||
|
2. Remove all business logic from routes
|
||||||
|
3. Replace all `HTTPException` with custom exceptions
|
||||||
|
4. Ensure all mappers are used consistently
|
||||||
|
5. Remove helper functions from routes (move to services/repositories)
|
||||||
|
|
||||||
|
## File Structure After Refactoring
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── routes/
|
||||||
|
│ ├── auth.py # Only HTTP handling, calls AuthService
|
||||||
|
│ ├── invites.py # Only HTTP handling, calls InviteService
|
||||||
|
│ ├── profile.py # Only HTTP handling, calls ProfileService
|
||||||
|
│ ├── availability.py # Only HTTP handling, calls AvailabilityService
|
||||||
|
│ ├── audit.py # Only HTTP handling, calls PriceService
|
||||||
|
│ └── exchange.py # Only HTTP handling, calls ExchangeService
|
||||||
|
├── services/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── auth.py # NEW: Registration, login logic
|
||||||
|
│ ├── invite.py # EXISTS: Expand with missing methods
|
||||||
|
│ ├── profile.py # NEW: Profile CRUD operations
|
||||||
|
│ ├── availability.py # NEW: Availability management
|
||||||
|
│ ├── price.py # NEW: Price fetching and history
|
||||||
|
│ └── exchange.py # EXISTS: Already good, minor additions
|
||||||
|
├── repositories/
|
||||||
|
│ └── ... (already good)
|
||||||
|
└── mappers/
|
||||||
|
└── ... (add PriceHistoryMapper)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Detailed Service Specifications
|
||||||
|
|
||||||
|
### AuthService
|
||||||
|
```python
|
||||||
|
class AuthService:
|
||||||
|
async def register_user(
|
||||||
|
self,
|
||||||
|
email: str,
|
||||||
|
password: str,
|
||||||
|
invite_identifier: str
|
||||||
|
) -> tuple[User, str]: # Returns (user, token)
|
||||||
|
"""Register new user with invite validation."""
|
||||||
|
|
||||||
|
async def login_user(
|
||||||
|
self,
|
||||||
|
email: str,
|
||||||
|
password: str
|
||||||
|
) -> tuple[User, str]: # Returns (user, token)
|
||||||
|
"""Authenticate user and create token."""
|
||||||
|
```
|
||||||
|
|
||||||
|
### ProfileService
|
||||||
|
```python
|
||||||
|
class ProfileService:
|
||||||
|
async def get_profile(self, user: User) -> ProfileResponse:
|
||||||
|
"""Get user profile with godfather email."""
|
||||||
|
|
||||||
|
async def update_profile(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
data: ProfileUpdate
|
||||||
|
) -> ProfileResponse:
|
||||||
|
"""Validate and update profile fields."""
|
||||||
|
```
|
||||||
|
|
||||||
|
### AvailabilityService
|
||||||
|
```python
|
||||||
|
class AvailabilityService:
|
||||||
|
async def get_availability_for_range(
|
||||||
|
self,
|
||||||
|
from_date: date,
|
||||||
|
to_date: date
|
||||||
|
) -> AvailabilityResponse:
|
||||||
|
"""Get availability grouped by date."""
|
||||||
|
|
||||||
|
async def set_availability_for_date(
|
||||||
|
self,
|
||||||
|
target_date: date,
|
||||||
|
slots: list[TimeSlot]
|
||||||
|
) -> AvailabilityDay:
|
||||||
|
"""Validate and set availability for a date."""
|
||||||
|
|
||||||
|
async def copy_availability(
|
||||||
|
self,
|
||||||
|
source_date: date,
|
||||||
|
target_dates: list[date]
|
||||||
|
) -> AvailabilityResponse:
|
||||||
|
"""Copy availability from source to target dates."""
|
||||||
|
```
|
||||||
|
|
||||||
|
### PriceService
|
||||||
|
```python
|
||||||
|
class PriceService:
|
||||||
|
async def get_recent_prices(self, limit: int = 20) -> list[PriceHistory]:
|
||||||
|
"""Get recent price history."""
|
||||||
|
|
||||||
|
async def fetch_and_store_price(self) -> PriceHistory:
|
||||||
|
"""Fetch price from Bitfinex and store (handles duplicates)."""
|
||||||
|
```
|
||||||
|
|
||||||
|
### InviteService (Expansion)
|
||||||
|
```python
|
||||||
|
class InviteService:
|
||||||
|
# Existing methods...
|
||||||
|
|
||||||
|
async def get_user_invites(self, user_id: int) -> list[Invite]:
|
||||||
|
"""Get all invites for a user."""
|
||||||
|
|
||||||
|
async def list_invites(
|
||||||
|
self,
|
||||||
|
page: int,
|
||||||
|
per_page: int,
|
||||||
|
status_filter: str | None = None,
|
||||||
|
godfather_id: int | None = None
|
||||||
|
) -> PaginatedInviteRecords:
|
||||||
|
"""List invites with pagination and filtering."""
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
1. Ensure all existing tests pass after each service creation
|
||||||
|
2. Add service-level unit tests
|
||||||
|
3. Keep route tests focused on HTTP concerns (status codes, response formats)
|
||||||
|
4. Move business logic tests to service tests
|
||||||
|
|
||||||
|
## Migration Order
|
||||||
|
1. **PriceService** (simplest, least dependencies)
|
||||||
|
2. **AvailabilityService** (self-contained)
|
||||||
|
3. **ProfileService** (simple CRUD)
|
||||||
|
4. **AuthService** (more complex, but isolated)
|
||||||
|
5. **InviteService expansion** (already exists, just expand)
|
||||||
|
6. **ExchangeService** (slot expansion logic)
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
- ✅ No `await db.execute()` calls in routes
|
||||||
|
- ✅ No business validation logic in routes
|
||||||
|
- ✅ No data transformation logic in routes
|
||||||
|
- ✅ All routes are thin wrappers around service calls
|
||||||
|
- ✅ All tests pass
|
||||||
|
- ✅ Code is more testable and maintainable
|
||||||
|
|
||||||
|
|
@ -51,6 +51,16 @@ class BadRequestError(APIError):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UnauthorizedError(APIError):
|
||||||
|
"""Unauthorized error (401)."""
|
||||||
|
|
||||||
|
def __init__(self, message: str = "Not authenticated"):
|
||||||
|
super().__init__(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ServiceUnavailableError(APIError):
|
class ServiceUnavailableError(APIError):
|
||||||
"""Service unavailable error (503)."""
|
"""Service unavailable error (503)."""
|
||||||
|
|
||||||
|
|
@ -59,3 +69,16 @@ class ServiceUnavailableError(APIError):
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
message=message,
|
message=message,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(HTTPException):
|
||||||
|
"""Validation error (422) with field-specific errors."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, field_errors: dict[str, str] | None = None):
|
||||||
|
detail: dict[str, str | dict[str, str]] = {"message": message}
|
||||||
|
if field_errors:
|
||||||
|
detail["field_errors"] = field_errors
|
||||||
|
super().__init__(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=detail,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
"""Response mappers for converting models to API response schemas."""
|
"""Response mappers for converting models to API response schemas."""
|
||||||
|
|
||||||
from models import Exchange, Invite
|
from models import Exchange, Invite, PriceHistory
|
||||||
from schemas import (
|
from schemas import (
|
||||||
AdminExchangeResponse,
|
AdminExchangeResponse,
|
||||||
ExchangeResponse,
|
ExchangeResponse,
|
||||||
ExchangeUserContact,
|
ExchangeUserContact,
|
||||||
InviteResponse,
|
InviteResponse,
|
||||||
|
PriceHistoryResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -89,3 +90,19 @@ class InviteMapper:
|
||||||
spent_at=invite.spent_at,
|
spent_at=invite.spent_at,
|
||||||
revoked_at=invite.revoked_at,
|
revoked_at=invite.revoked_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PriceHistoryMapper:
|
||||||
|
"""Mapper for PriceHistory model to response schemas."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def to_response(record: PriceHistory) -> PriceHistoryResponse:
|
||||||
|
"""Convert a PriceHistory model to PriceHistoryResponse schema."""
|
||||||
|
return PriceHistoryResponse(
|
||||||
|
id=record.id,
|
||||||
|
source=record.source,
|
||||||
|
pair=record.pair,
|
||||||
|
price=record.price,
|
||||||
|
timestamp=record.timestamp,
|
||||||
|
created_at=record.created_at,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
"""Repository layer for database queries."""
|
"""Repository layer for database queries."""
|
||||||
|
|
||||||
|
from repositories.availability import AvailabilityRepository
|
||||||
|
from repositories.exchange import ExchangeRepository
|
||||||
|
from repositories.invite import InviteRepository
|
||||||
from repositories.price import PriceRepository
|
from repositories.price import PriceRepository
|
||||||
|
from repositories.role import RoleRepository
|
||||||
from repositories.user import UserRepository
|
from repositories.user import UserRepository
|
||||||
|
|
||||||
__all__ = ["PriceRepository", "UserRepository"]
|
__all__ = [
|
||||||
|
"AvailabilityRepository",
|
||||||
|
"ExchangeRepository",
|
||||||
|
"InviteRepository",
|
||||||
|
"PriceRepository",
|
||||||
|
"RoleRepository",
|
||||||
|
"UserRepository",
|
||||||
|
]
|
||||||
|
|
|
||||||
70
backend/repositories/availability.py
Normal file
70
backend/repositories/availability.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
"""Availability repository for database queries."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy import and_, delete, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from models import Availability
|
||||||
|
|
||||||
|
|
||||||
|
class AvailabilityRepository:
|
||||||
|
"""Repository for availability-related database queries."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def get_by_date_range(
|
||||||
|
self, from_date: date, to_date: date
|
||||||
|
) -> list[Availability]:
|
||||||
|
"""Get availability slots for a date range."""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Availability)
|
||||||
|
.where(and_(Availability.date >= from_date, Availability.date <= to_date))
|
||||||
|
.order_by(Availability.date, Availability.start_time)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def get_by_date(self, target_date: date) -> list[Availability]:
|
||||||
|
"""Get availability slots for a specific date."""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Availability)
|
||||||
|
.where(Availability.date == target_date)
|
||||||
|
.order_by(Availability.start_time)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def delete_by_date(self, target_date: date) -> None:
|
||||||
|
"""Delete all availability for a specific date."""
|
||||||
|
await self.db.execute(
|
||||||
|
delete(Availability).where(Availability.date == target_date)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def create(self, availability: Availability) -> Availability:
|
||||||
|
"""
|
||||||
|
Create a new availability record.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
availability: Availability instance to persist
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created Availability record (flushed to get ID)
|
||||||
|
"""
|
||||||
|
self.db.add(availability)
|
||||||
|
await self.db.flush()
|
||||||
|
return availability
|
||||||
|
|
||||||
|
async def create_multiple(self, availabilities: list[Availability]) -> None:
|
||||||
|
"""
|
||||||
|
Create multiple availability records in a single transaction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
availabilities: List of Availability instances to persist
|
||||||
|
"""
|
||||||
|
for availability in availabilities:
|
||||||
|
self.db.add(availability)
|
||||||
|
await self.db.flush()
|
||||||
|
|
||||||
|
async def commit(self) -> None:
|
||||||
|
"""Commit the current transaction."""
|
||||||
|
await self.db.commit()
|
||||||
192
backend/repositories/exchange.py
Normal file
192
backend/repositories/exchange.py
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
"""Exchange repository for database queries."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, date, datetime, time
|
||||||
|
|
||||||
|
from sqlalchemy import and_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
|
from models import Exchange, ExchangeStatus, User
|
||||||
|
|
||||||
|
|
||||||
|
class ExchangeRepository:
|
||||||
|
"""Repository for exchange-related database queries."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def get_by_public_id(
|
||||||
|
self, public_id: uuid.UUID, load_user: bool = False
|
||||||
|
) -> Exchange | None:
|
||||||
|
"""Get an exchange by public ID."""
|
||||||
|
query = select(Exchange).where(Exchange.public_id == public_id)
|
||||||
|
if load_user:
|
||||||
|
query = query.options(joinedload(Exchange.user))
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_by_user_id(
|
||||||
|
self, user_id: int, order_by_desc: bool = True
|
||||||
|
) -> list[Exchange]:
|
||||||
|
"""Get all exchanges for a user."""
|
||||||
|
query = select(Exchange).where(Exchange.user_id == user_id)
|
||||||
|
if order_by_desc:
|
||||||
|
query = query.order_by(Exchange.slot_start.desc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(Exchange.slot_start.asc())
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def get_upcoming_booked(self) -> list[Exchange]:
|
||||||
|
"""Get all upcoming booked trades, sorted by slot time ascending."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
query = (
|
||||||
|
select(Exchange)
|
||||||
|
.options(joinedload(Exchange.user))
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Exchange.slot_start > now,
|
||||||
|
Exchange.status == ExchangeStatus.BOOKED,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Exchange.slot_start.asc())
|
||||||
|
)
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def get_past_trades(
|
||||||
|
self,
|
||||||
|
status: ExchangeStatus | None = None,
|
||||||
|
start_date: date | None = None,
|
||||||
|
end_date: date | None = None,
|
||||||
|
user_search: str | None = None,
|
||||||
|
) -> list[Exchange]:
|
||||||
|
"""
|
||||||
|
Get past trades with optional filters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status: Filter by exchange status
|
||||||
|
start_date: Filter by slot_start date (inclusive start)
|
||||||
|
end_date: Filter by slot_start date (inclusive end)
|
||||||
|
user_search: Search by user email (partial match, case-insensitive)
|
||||||
|
"""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
# Start with base query for past trades
|
||||||
|
query = (
|
||||||
|
select(Exchange)
|
||||||
|
.options(joinedload(Exchange.user))
|
||||||
|
.where(
|
||||||
|
(Exchange.slot_start <= now)
|
||||||
|
| (Exchange.status != ExchangeStatus.BOOKED)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply status filter
|
||||||
|
if status:
|
||||||
|
query = query.where(Exchange.status == status)
|
||||||
|
|
||||||
|
# Apply date range filter
|
||||||
|
if start_date:
|
||||||
|
start_dt = datetime.combine(start_date, time.min, tzinfo=UTC)
|
||||||
|
query = query.where(Exchange.slot_start >= start_dt)
|
||||||
|
if end_date:
|
||||||
|
end_dt = datetime.combine(end_date, time.max, tzinfo=UTC)
|
||||||
|
query = query.where(Exchange.slot_start <= end_dt)
|
||||||
|
|
||||||
|
# Apply user search filter
|
||||||
|
if user_search:
|
||||||
|
query = query.join(Exchange.user).where(
|
||||||
|
User.email.ilike(f"%{user_search}%")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Order by most recent first
|
||||||
|
query = query.order_by(Exchange.slot_start.desc())
|
||||||
|
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def get_by_slot_start(
|
||||||
|
self, slot_start: datetime, status: ExchangeStatus | None = None
|
||||||
|
) -> Exchange | None:
|
||||||
|
"""Get exchange by slot start time, optionally filtered by status."""
|
||||||
|
query = select(Exchange).where(Exchange.slot_start == slot_start)
|
||||||
|
if status:
|
||||||
|
query = query.where(Exchange.status == status)
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_by_user_and_date_range(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
start_date: date,
|
||||||
|
end_date: date,
|
||||||
|
status: ExchangeStatus | None = None,
|
||||||
|
) -> list[Exchange]:
|
||||||
|
"""Get exchanges for a user within a date range."""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
start_dt = datetime.combine(start_date, time.min, tzinfo=UTC)
|
||||||
|
# End date should be exclusive (next day at 00:00:00)
|
||||||
|
end_dt = datetime.combine(end_date, time.min, tzinfo=UTC) + timedelta(days=1)
|
||||||
|
|
||||||
|
query = select(Exchange).where(
|
||||||
|
and_(
|
||||||
|
Exchange.user_id == user_id,
|
||||||
|
Exchange.slot_start >= start_dt,
|
||||||
|
Exchange.slot_start < end_dt,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if status:
|
||||||
|
query = query.where(Exchange.status == status)
|
||||||
|
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def get_booked_slots_for_date(self, target_date: date) -> set[datetime]:
|
||||||
|
"""Get set of booked slot start times for a specific date."""
|
||||||
|
from utils.date_queries import date_to_end_datetime, date_to_start_datetime
|
||||||
|
|
||||||
|
date_start = date_to_start_datetime(target_date)
|
||||||
|
date_end = date_to_end_datetime(target_date)
|
||||||
|
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Exchange.slot_start).where(
|
||||||
|
and_(
|
||||||
|
Exchange.slot_start >= date_start,
|
||||||
|
Exchange.slot_start <= date_end,
|
||||||
|
Exchange.status == ExchangeStatus.BOOKED,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {row[0] for row in result.all()}
|
||||||
|
|
||||||
|
async def create(self, exchange: Exchange) -> Exchange:
|
||||||
|
"""
|
||||||
|
Create a new exchange record.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
exchange: Exchange instance to persist
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created Exchange record (committed and refreshed)
|
||||||
|
"""
|
||||||
|
self.db.add(exchange)
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(exchange)
|
||||||
|
return exchange
|
||||||
|
|
||||||
|
async def update(self, exchange: Exchange) -> Exchange:
|
||||||
|
"""
|
||||||
|
Update an existing exchange record.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
exchange: Exchange instance to update
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated Exchange record (committed and refreshed)
|
||||||
|
"""
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(exchange)
|
||||||
|
return exchange
|
||||||
128
backend/repositories/invite.py
Normal file
128
backend/repositories/invite.py
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
"""Invite repository for database queries."""
|
||||||
|
|
||||||
|
from sqlalchemy import desc, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
|
from models import Invite, InviteStatus
|
||||||
|
|
||||||
|
|
||||||
|
class InviteRepository:
|
||||||
|
"""Repository for invite-related database queries."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def get_by_identifier(self, identifier: str) -> Invite | None:
|
||||||
|
"""Get an invite by identifier, eagerly loading relationships."""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Invite)
|
||||||
|
.options(joinedload(Invite.godfather), joinedload(Invite.used_by))
|
||||||
|
.where(Invite.identifier == identifier)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_by_id(self, invite_id: int) -> Invite | None:
|
||||||
|
"""Get an invite by ID, eagerly loading relationships."""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Invite)
|
||||||
|
.options(joinedload(Invite.godfather), joinedload(Invite.used_by))
|
||||||
|
.where(Invite.id == invite_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_by_godfather_id(
|
||||||
|
self, godfather_id: int, order_by_desc: bool = True
|
||||||
|
) -> list[Invite]:
|
||||||
|
"""Get all invites for a godfather user, eagerly loading relationships."""
|
||||||
|
query = (
|
||||||
|
select(Invite)
|
||||||
|
.options(joinedload(Invite.used_by))
|
||||||
|
.where(Invite.godfather_id == godfather_id)
|
||||||
|
)
|
||||||
|
if order_by_desc:
|
||||||
|
query = query.order_by(desc(Invite.created_at))
|
||||||
|
else:
|
||||||
|
query = query.order_by(Invite.created_at)
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def count(
|
||||||
|
self,
|
||||||
|
status: InviteStatus | None = None,
|
||||||
|
godfather_id: int | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Count invites matching filters."""
|
||||||
|
query = select(func.count(Invite.id))
|
||||||
|
if status:
|
||||||
|
query = query.where(Invite.status == status)
|
||||||
|
if godfather_id:
|
||||||
|
query = query.where(Invite.godfather_id == godfather_id)
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
return result.scalar() or 0
|
||||||
|
|
||||||
|
async def list_paginated(
|
||||||
|
self,
|
||||||
|
page: int,
|
||||||
|
per_page: int,
|
||||||
|
status: InviteStatus | None = None,
|
||||||
|
godfather_id: int | None = None,
|
||||||
|
) -> list[Invite]:
|
||||||
|
"""Get paginated list of invites, eagerly loading relationships."""
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
query = select(Invite).options(
|
||||||
|
joinedload(Invite.godfather), joinedload(Invite.used_by)
|
||||||
|
)
|
||||||
|
if status:
|
||||||
|
query = query.where(Invite.status == status)
|
||||||
|
if godfather_id:
|
||||||
|
query = query.where(Invite.godfather_id == godfather_id)
|
||||||
|
query = query.order_by(desc(Invite.created_at)).offset(offset).limit(per_page)
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def create(self, invite: Invite) -> Invite:
|
||||||
|
"""
|
||||||
|
Create a new invite record.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
invite: Invite instance to persist
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created Invite record (committed and refreshed)
|
||||||
|
"""
|
||||||
|
self.db.add(invite)
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(invite)
|
||||||
|
return invite
|
||||||
|
|
||||||
|
async def update(self, invite: Invite) -> Invite:
|
||||||
|
"""
|
||||||
|
Update an existing invite record.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
invite: Invite instance to update
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated Invite record (committed and refreshed)
|
||||||
|
"""
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(invite)
|
||||||
|
return invite
|
||||||
|
|
||||||
|
async def reload_with_relationships(self, invite_id: int) -> Invite:
|
||||||
|
"""
|
||||||
|
Reload an invite with all relationships eagerly loaded.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
invite_id: ID of the invite to reload
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Invite record with relationships loaded
|
||||||
|
"""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Invite)
|
||||||
|
.options(joinedload(Invite.godfather), joinedload(Invite.used_by))
|
||||||
|
.where(Invite.id == invite_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
"""Price repository for database queries."""
|
"""Price repository for database queries."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import desc, select
|
from sqlalchemy import desc, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
@ -25,3 +27,47 @@ class PriceRepository:
|
||||||
)
|
)
|
||||||
result = await self.db.execute(query)
|
result = await self.db.execute(query)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_recent(self, limit: int = 20) -> list[PriceHistory]:
|
||||||
|
"""Get the most recent price history records."""
|
||||||
|
query = select(PriceHistory).order_by(desc(PriceHistory.timestamp)).limit(limit)
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def get_by_timestamp(
|
||||||
|
self,
|
||||||
|
timestamp: str | datetime,
|
||||||
|
source: str = SOURCE_BITFINEX,
|
||||||
|
pair: str = PAIR_BTC_EUR,
|
||||||
|
) -> PriceHistory | None:
|
||||||
|
"""Get a price record by timestamp."""
|
||||||
|
# Convert string timestamp to datetime if needed
|
||||||
|
timestamp_dt: datetime
|
||||||
|
if isinstance(timestamp, str):
|
||||||
|
timestamp_dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
||||||
|
else:
|
||||||
|
timestamp_dt = timestamp
|
||||||
|
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(PriceHistory).where(
|
||||||
|
PriceHistory.source == source,
|
||||||
|
PriceHistory.pair == pair,
|
||||||
|
PriceHistory.timestamp == timestamp_dt,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def create(self, record: PriceHistory) -> PriceHistory:
|
||||||
|
"""
|
||||||
|
Create a new price history record.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
record: PriceHistory instance to persist
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created PriceHistory record (refreshed from database)
|
||||||
|
"""
|
||||||
|
self.db.add(record)
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(record)
|
||||||
|
return record
|
||||||
|
|
|
||||||
18
backend/repositories/role.py
Normal file
18
backend/repositories/role.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
"""Role repository for database queries."""
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from models import Role
|
||||||
|
|
||||||
|
|
||||||
|
class RoleRepository:
|
||||||
|
"""Repository for role-related database queries."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def get_by_name(self, name: str) -> Role | None:
|
||||||
|
"""Get a role by name."""
|
||||||
|
result = await self.db.execute(select(Role).where(Role.name == name))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
@ -21,3 +21,44 @@ class UserRepository:
|
||||||
"""Get a user by ID."""
|
"""Get a user by ID."""
|
||||||
result = await self.db.execute(select(User).where(User.id == user_id))
|
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_godfather_email(self, godfather_id: int | None) -> str | None:
|
||||||
|
"""Get the email of a godfather user by ID."""
|
||||||
|
if not godfather_id:
|
||||||
|
return None
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(User.email).where(User.id == godfather_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def create(self, user: User) -> User:
|
||||||
|
"""
|
||||||
|
Create a new user record.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User instance to persist
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created User record (flushed to get ID)
|
||||||
|
"""
|
||||||
|
self.db.add(user)
|
||||||
|
await self.db.flush()
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def update(self, user: User) -> User:
|
||||||
|
"""
|
||||||
|
Update an existing user record.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: User instance to update
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated User record (refreshed from database)
|
||||||
|
"""
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def refresh(self, user: User) -> None:
|
||||||
|
"""Refresh a user instance from the database."""
|
||||||
|
await self.db.refresh(user)
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,22 @@
|
||||||
"""Audit routes for price history."""
|
"""Audit routes for price history."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import desc, select
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from auth import require_permission
|
from auth import require_permission
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import Permission, PriceHistory, User
|
from mappers import PriceHistoryMapper
|
||||||
from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX, fetch_btc_eur_price
|
from models import Permission, User
|
||||||
from schemas import PriceHistoryResponse
|
from schemas import PriceHistoryResponse
|
||||||
|
from services.price import PriceService
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/audit", tags=["audit"])
|
router = APIRouter(prefix="/api/audit", tags=["audit"])
|
||||||
|
|
||||||
|
|
||||||
def _to_price_history_response(record: PriceHistory) -> PriceHistoryResponse:
|
|
||||||
return PriceHistoryResponse(
|
|
||||||
id=record.id,
|
|
||||||
source=record.source,
|
|
||||||
pair=record.pair,
|
|
||||||
price=record.price,
|
|
||||||
timestamp=record.timestamp,
|
|
||||||
created_at=record.created_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Price History Endpoints
|
# Price History Endpoints
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
PRICE_HISTORY_LIMIT = 20
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/price-history", response_model=list[PriceHistoryResponse])
|
@router.get("/price-history", response_model=list[PriceHistoryResponse])
|
||||||
async def get_price_history(
|
async def get_price_history(
|
||||||
|
|
@ -38,15 +24,10 @@ async def get_price_history(
|
||||||
_current_user: User = Depends(require_permission(Permission.VIEW_AUDIT)),
|
_current_user: User = Depends(require_permission(Permission.VIEW_AUDIT)),
|
||||||
) -> list[PriceHistoryResponse]:
|
) -> list[PriceHistoryResponse]:
|
||||||
"""Get the 20 most recent price history records."""
|
"""Get the 20 most recent price history records."""
|
||||||
query = (
|
service = PriceService(db)
|
||||||
select(PriceHistory)
|
records = await service.get_recent_prices()
|
||||||
.order_by(desc(PriceHistory.timestamp))
|
|
||||||
.limit(PRICE_HISTORY_LIMIT)
|
|
||||||
)
|
|
||||||
result = await db.execute(query)
|
|
||||||
records = result.scalars().all()
|
|
||||||
|
|
||||||
return [_to_price_history_response(record) for record in records]
|
return [PriceHistoryMapper.to_response(record) for record in records]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/price-history/fetch", response_model=PriceHistoryResponse)
|
@router.post("/price-history/fetch", response_model=PriceHistoryResponse)
|
||||||
|
|
@ -55,28 +36,7 @@ async def fetch_price_now(
|
||||||
_current_user: User = Depends(require_permission(Permission.FETCH_PRICE)),
|
_current_user: User = Depends(require_permission(Permission.FETCH_PRICE)),
|
||||||
) -> PriceHistoryResponse:
|
) -> PriceHistoryResponse:
|
||||||
"""Manually trigger a price fetch from Bitfinex."""
|
"""Manually trigger a price fetch from Bitfinex."""
|
||||||
price, timestamp = await fetch_btc_eur_price()
|
service = PriceService(db)
|
||||||
|
record = await service.fetch_and_store_price()
|
||||||
|
|
||||||
record = PriceHistory(
|
return PriceHistoryMapper.to_response(record)
|
||||||
source=SOURCE_BITFINEX,
|
|
||||||
pair=PAIR_BTC_EUR,
|
|
||||||
price=price,
|
|
||||||
timestamp=timestamp,
|
|
||||||
)
|
|
||||||
db.add(record)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(record)
|
|
||||||
except IntegrityError:
|
|
||||||
# Duplicate timestamp - return the existing record
|
|
||||||
await db.rollback()
|
|
||||||
query = select(PriceHistory).where(
|
|
||||||
PriceHistory.source == SOURCE_BITFINEX,
|
|
||||||
PriceHistory.pair == PAIR_BTC_EUR,
|
|
||||||
PriceHistory.timestamp == timestamp,
|
|
||||||
)
|
|
||||||
result = await db.execute(query)
|
|
||||||
record = result.scalar_one()
|
|
||||||
|
|
||||||
return _to_price_history_response(record)
|
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,19 @@
|
||||||
"""Authentication routes for register, login, logout, and current user."""
|
"""Authentication routes for register, login, logout, and current user."""
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from fastapi import APIRouter, Depends, Response
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from auth import (
|
from auth import (
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES,
|
ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||||
COOKIE_NAME,
|
COOKIE_NAME,
|
||||||
COOKIE_SECURE,
|
COOKIE_SECURE,
|
||||||
authenticate_user,
|
|
||||||
build_user_response,
|
build_user_response,
|
||||||
create_access_token,
|
|
||||||
get_current_user,
|
get_current_user,
|
||||||
get_password_hash,
|
|
||||||
get_user_by_email,
|
|
||||||
)
|
)
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from invite_utils import normalize_identifier
|
from models import User
|
||||||
from models import ROLE_REGULAR, Invite, InviteStatus, Role, User
|
|
||||||
from schemas import RegisterWithInvite, UserLogin, UserResponse
|
from schemas import RegisterWithInvite, UserLogin, UserResponse
|
||||||
|
from services.auth import AuthService
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
@ -37,12 +30,6 @@ def set_auth_cookie(response: Response, token: str) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_default_role(db: AsyncSession) -> Role | None:
|
|
||||||
"""Get the default 'regular' role for new users."""
|
|
||||||
result = await db.execute(select(Role).where(Role.name == ROLE_REGULAR))
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register", response_model=UserResponse)
|
@router.post("/register", response_model=UserResponse)
|
||||||
async def register(
|
async def register(
|
||||||
user_data: RegisterWithInvite,
|
user_data: RegisterWithInvite,
|
||||||
|
|
@ -50,51 +37,13 @@ async def register(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> UserResponse:
|
) -> UserResponse:
|
||||||
"""Register a new user using an invite code."""
|
"""Register a new user using an invite code."""
|
||||||
# Validate invite
|
service = AuthService(db)
|
||||||
normalized_identifier = normalize_identifier(user_data.invite_identifier)
|
user, access_token = await service.register_user(
|
||||||
query = select(Invite).where(Invite.identifier == normalized_identifier)
|
|
||||||
result = await db.execute(query)
|
|
||||||
invite = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
# Return same error for not found, spent, and revoked to avoid information leakage
|
|
||||||
if not invite or invite.status in (InviteStatus.SPENT, InviteStatus.REVOKED):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Invalid invite code",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check email not already taken
|
|
||||||
existing_user = await get_user_by_email(db, user_data.email)
|
|
||||||
if existing_user:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Email already registered",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create user with godfather
|
|
||||||
user = User(
|
|
||||||
email=user_data.email,
|
email=user_data.email,
|
||||||
hashed_password=get_password_hash(user_data.password),
|
password=user_data.password,
|
||||||
godfather_id=invite.godfather_id,
|
invite_identifier=user_data.invite_identifier,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Assign default role
|
|
||||||
default_role = await get_default_role(db)
|
|
||||||
if default_role:
|
|
||||||
user.roles.append(default_role)
|
|
||||||
|
|
||||||
db.add(user)
|
|
||||||
await db.flush() # Get user ID
|
|
||||||
|
|
||||||
# Mark invite as spent
|
|
||||||
invite.status = InviteStatus.SPENT
|
|
||||||
invite.used_by_id = user.id
|
|
||||||
invite.spent_at = datetime.now(UTC)
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(user)
|
|
||||||
|
|
||||||
access_token = create_access_token(data={"sub": str(user.id)})
|
|
||||||
set_auth_cookie(response, access_token)
|
set_auth_cookie(response, access_token)
|
||||||
return await build_user_response(user, db)
|
return await build_user_response(user, db)
|
||||||
|
|
||||||
|
|
@ -106,14 +55,11 @@ async def login(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> UserResponse:
|
) -> UserResponse:
|
||||||
"""Authenticate a user and return their info with an auth cookie."""
|
"""Authenticate a user and return their info with an auth cookie."""
|
||||||
user = await authenticate_user(db, user_data.email, user_data.password)
|
service = AuthService(db)
|
||||||
if not user:
|
user, access_token = await service.login_user(
|
||||||
raise HTTPException(
|
email=user_data.email, password=user_data.password
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
)
|
||||||
detail="Incorrect email or password",
|
|
||||||
)
|
|
||||||
|
|
||||||
access_token = create_access_token(data={"sub": str(user.id)})
|
|
||||||
set_auth_cookie(response, access_token)
|
set_auth_cookie(response, access_token)
|
||||||
return await build_user_response(user, db)
|
return await build_user_response(user, db)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,19 @@
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy import and_, delete, select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from auth import require_permission
|
from auth import require_permission
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from date_validation import validate_date_in_range
|
from models import Permission, User
|
||||||
from models import Availability, Permission, User
|
|
||||||
from schemas import (
|
from schemas import (
|
||||||
AvailabilityDay,
|
AvailabilityDay,
|
||||||
AvailabilityResponse,
|
AvailabilityResponse,
|
||||||
CopyAvailabilityRequest,
|
CopyAvailabilityRequest,
|
||||||
SetAvailabilityRequest,
|
SetAvailabilityRequest,
|
||||||
TimeSlot,
|
|
||||||
)
|
)
|
||||||
|
from services.availability import AvailabilityService
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/availability", tags=["availability"])
|
router = APIRouter(prefix="/api/admin/availability", tags=["availability"])
|
||||||
|
|
||||||
|
|
@ -29,38 +27,8 @@ async def get_availability(
|
||||||
_current_user: User = Depends(require_permission(Permission.MANAGE_AVAILABILITY)),
|
_current_user: User = Depends(require_permission(Permission.MANAGE_AVAILABILITY)),
|
||||||
) -> AvailabilityResponse:
|
) -> AvailabilityResponse:
|
||||||
"""Get availability slots for a date range."""
|
"""Get availability slots for a date range."""
|
||||||
if from_date > to_date:
|
service = AvailabilityService(db)
|
||||||
raise HTTPException(
|
return await service.get_availability_for_range(from_date, to_date)
|
||||||
status_code=400,
|
|
||||||
detail="'from' date must be before or equal to 'to' date",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Query availability in range
|
|
||||||
result = await db.execute(
|
|
||||||
select(Availability)
|
|
||||||
.where(and_(Availability.date >= from_date, Availability.date <= to_date))
|
|
||||||
.order_by(Availability.date, Availability.start_time)
|
|
||||||
)
|
|
||||||
slots = result.scalars().all()
|
|
||||||
|
|
||||||
# Group by date
|
|
||||||
days_dict: dict[date, list[TimeSlot]] = {}
|
|
||||||
for slot in slots:
|
|
||||||
if slot.date not in days_dict:
|
|
||||||
days_dict[slot.date] = []
|
|
||||||
days_dict[slot.date].append(
|
|
||||||
TimeSlot(
|
|
||||||
start_time=slot.start_time,
|
|
||||||
end_time=slot.end_time,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Convert to response format
|
|
||||||
days = [
|
|
||||||
AvailabilityDay(date=d, slots=days_dict[d]) for d in sorted(days_dict.keys())
|
|
||||||
]
|
|
||||||
|
|
||||||
return AvailabilityResponse(days=days)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("", response_model=AvailabilityDay)
|
@router.put("", response_model=AvailabilityDay)
|
||||||
|
|
@ -70,44 +38,8 @@ async def set_availability(
|
||||||
_current_user: User = Depends(require_permission(Permission.MANAGE_AVAILABILITY)),
|
_current_user: User = Depends(require_permission(Permission.MANAGE_AVAILABILITY)),
|
||||||
) -> AvailabilityDay:
|
) -> AvailabilityDay:
|
||||||
"""Set availability for a specific date. Replaces any existing availability."""
|
"""Set availability for a specific date. Replaces any existing availability."""
|
||||||
validate_date_in_range(request.date, context="set availability")
|
service = AvailabilityService(db)
|
||||||
|
return await service.set_availability_for_date(request.date, request.slots)
|
||||||
# Validate slots don't overlap
|
|
||||||
sorted_slots = sorted(request.slots, key=lambda s: s.start_time)
|
|
||||||
for i in range(len(sorted_slots) - 1):
|
|
||||||
if sorted_slots[i].end_time > sorted_slots[i + 1].start_time:
|
|
||||||
end = sorted_slots[i].end_time
|
|
||||||
start = sorted_slots[i + 1].start_time
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Time slots overlap: slot ending at {end} "
|
|
||||||
f"overlaps with slot starting at {start}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate each slot's end_time > start_time
|
|
||||||
for slot in request.slots:
|
|
||||||
if slot.end_time <= slot.start_time:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Invalid time slot: end time {slot.end_time} "
|
|
||||||
f"must be after start time {slot.start_time}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delete existing availability for this date
|
|
||||||
await db.execute(delete(Availability).where(Availability.date == request.date))
|
|
||||||
|
|
||||||
# Create new availability slots
|
|
||||||
for slot in request.slots:
|
|
||||||
availability = Availability(
|
|
||||||
date=request.date,
|
|
||||||
start_time=slot.start_time,
|
|
||||||
end_time=slot.end_time,
|
|
||||||
)
|
|
||||||
db.add(availability)
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
return AvailabilityDay(date=request.date, slots=request.slots)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/copy", response_model=AvailabilityResponse)
|
@router.post("/copy", response_model=AvailabilityResponse)
|
||||||
|
|
@ -117,62 +49,5 @@ async def copy_availability(
|
||||||
_current_user: User = Depends(require_permission(Permission.MANAGE_AVAILABILITY)),
|
_current_user: User = Depends(require_permission(Permission.MANAGE_AVAILABILITY)),
|
||||||
) -> AvailabilityResponse:
|
) -> AvailabilityResponse:
|
||||||
"""Copy availability from one day to multiple target days."""
|
"""Copy availability from one day to multiple target days."""
|
||||||
# Validate source date is in range
|
service = AvailabilityService(db)
|
||||||
validate_date_in_range(request.source_date, context="copy from")
|
return await service.copy_availability(request.source_date, request.target_dates)
|
||||||
|
|
||||||
# Validate target dates
|
|
||||||
for target_date in request.target_dates:
|
|
||||||
validate_date_in_range(target_date, context="copy to")
|
|
||||||
|
|
||||||
# Get source availability
|
|
||||||
result = await db.execute(
|
|
||||||
select(Availability)
|
|
||||||
.where(Availability.date == request.source_date)
|
|
||||||
.order_by(Availability.start_time)
|
|
||||||
)
|
|
||||||
source_slots = result.scalars().all()
|
|
||||||
|
|
||||||
if not source_slots:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"No availability found for source date {request.source_date}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Copy to each target date within a single atomic transaction
|
|
||||||
# All deletes and inserts happen before commit, ensuring atomicity
|
|
||||||
copied_days: list[AvailabilityDay] = []
|
|
||||||
try:
|
|
||||||
for target_date in request.target_dates:
|
|
||||||
if target_date == request.source_date:
|
|
||||||
continue # Skip copying to self
|
|
||||||
|
|
||||||
# Delete existing availability for target date
|
|
||||||
del_query = delete(Availability).where(Availability.date == target_date)
|
|
||||||
await db.execute(del_query)
|
|
||||||
|
|
||||||
# Copy slots
|
|
||||||
target_slots: list[TimeSlot] = []
|
|
||||||
for source_slot in source_slots:
|
|
||||||
new_availability = Availability(
|
|
||||||
date=target_date,
|
|
||||||
start_time=source_slot.start_time,
|
|
||||||
end_time=source_slot.end_time,
|
|
||||||
)
|
|
||||||
db.add(new_availability)
|
|
||||||
target_slots.append(
|
|
||||||
TimeSlot(
|
|
||||||
start_time=source_slot.start_time,
|
|
||||||
end_time=source_slot.end_time,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
copied_days.append(AvailabilityDay(date=target_date, slots=target_slots))
|
|
||||||
|
|
||||||
# Commit all changes atomically
|
|
||||||
await db.commit()
|
|
||||||
except Exception:
|
|
||||||
# Rollback on any error to maintain atomicity
|
|
||||||
await db.rollback()
|
|
||||||
raise
|
|
||||||
|
|
||||||
return AvailabilityResponse(days=copied_days)
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,16 @@
|
||||||
"""Exchange routes for Bitcoin trading."""
|
"""Exchange routes for Bitcoin trading."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, date, datetime, time, timedelta
|
from datetime import date
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy import and_, select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import joinedload
|
|
||||||
|
|
||||||
from auth import require_permission
|
from auth import require_permission
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from date_validation import validate_date_in_range
|
|
||||||
from exceptions import BadRequestError
|
|
||||||
from mappers import ExchangeMapper
|
from mappers import ExchangeMapper
|
||||||
from models import (
|
from models import (
|
||||||
Availability,
|
|
||||||
BitcoinTransferMethod,
|
BitcoinTransferMethod,
|
||||||
Exchange,
|
|
||||||
ExchangeStatus,
|
ExchangeStatus,
|
||||||
Permission,
|
Permission,
|
||||||
PriceHistory,
|
PriceHistory,
|
||||||
|
|
@ -24,11 +18,11 @@ from models import (
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX, fetch_btc_eur_price
|
from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX, fetch_btc_eur_price
|
||||||
|
from repositories.exchange import ExchangeRepository
|
||||||
from repositories.price import PriceRepository
|
from repositories.price import PriceRepository
|
||||||
from schemas import (
|
from schemas import (
|
||||||
AdminExchangeResponse,
|
AdminExchangeResponse,
|
||||||
AvailableSlotsResponse,
|
AvailableSlotsResponse,
|
||||||
BookableSlot,
|
|
||||||
ExchangeConfigResponse,
|
ExchangeConfigResponse,
|
||||||
ExchangePriceResponse,
|
ExchangePriceResponse,
|
||||||
ExchangeRequest,
|
ExchangeRequest,
|
||||||
|
|
@ -42,8 +36,8 @@ from shared_constants import (
|
||||||
EUR_TRADE_MAX,
|
EUR_TRADE_MAX,
|
||||||
EUR_TRADE_MIN,
|
EUR_TRADE_MIN,
|
||||||
PREMIUM_PERCENTAGE,
|
PREMIUM_PERCENTAGE,
|
||||||
SLOT_DURATION_MINUTES,
|
|
||||||
)
|
)
|
||||||
|
from utils.enum_validation import validate_enum
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/exchange", tags=["exchange"])
|
router = APIRouter(prefix="/api/exchange", tags=["exchange"])
|
||||||
|
|
||||||
|
|
@ -150,30 +144,6 @@ async def get_exchange_price(
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
def _expand_availability_to_slots(
|
|
||||||
avail: Availability, slot_date: date, booked_starts: set[datetime]
|
|
||||||
) -> list[BookableSlot]:
|
|
||||||
"""
|
|
||||||
Expand an availability block into individual slots, filtering out booked ones.
|
|
||||||
"""
|
|
||||||
slots: list[BookableSlot] = []
|
|
||||||
|
|
||||||
# Start from the availability's start time
|
|
||||||
current_start = datetime.combine(slot_date, avail.start_time, tzinfo=UTC)
|
|
||||||
avail_end = datetime.combine(slot_date, avail.end_time, tzinfo=UTC)
|
|
||||||
|
|
||||||
while current_start + timedelta(minutes=SLOT_DURATION_MINUTES) <= avail_end:
|
|
||||||
slot_end = current_start + timedelta(minutes=SLOT_DURATION_MINUTES)
|
|
||||||
|
|
||||||
# Only include if not already booked
|
|
||||||
if current_start not in booked_starts:
|
|
||||||
slots.append(BookableSlot(start_time=current_start, end_time=slot_end))
|
|
||||||
|
|
||||||
current_start = slot_end
|
|
||||||
|
|
||||||
return slots
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/slots", response_model=AvailableSlotsResponse)
|
@router.get("/slots", response_model=AvailableSlotsResponse)
|
||||||
async def get_available_slots(
|
async def get_available_slots(
|
||||||
date_param: date = Query(..., alias="date"),
|
date_param: date = Query(..., alias="date"),
|
||||||
|
|
@ -187,42 +157,8 @@ async def get_available_slots(
|
||||||
- Fall within admin-defined availability windows
|
- Fall within admin-defined availability windows
|
||||||
- Are not already booked by another user
|
- Are not already booked by another user
|
||||||
"""
|
"""
|
||||||
validate_date_in_range(date_param, context="book")
|
service = ExchangeService(db)
|
||||||
|
return await service.get_available_slots(date_param)
|
||||||
# Get availability for the date
|
|
||||||
result = await db.execute(
|
|
||||||
select(Availability).where(Availability.date == date_param)
|
|
||||||
)
|
|
||||||
availabilities = result.scalars().all()
|
|
||||||
|
|
||||||
if not availabilities:
|
|
||||||
return AvailableSlotsResponse(date=date_param, slots=[])
|
|
||||||
|
|
||||||
# Get already booked slots for the date
|
|
||||||
date_start = datetime.combine(date_param, time.min, tzinfo=UTC)
|
|
||||||
date_end = datetime.combine(date_param, time.max, tzinfo=UTC)
|
|
||||||
|
|
||||||
result = await db.execute(
|
|
||||||
select(Exchange.slot_start).where(
|
|
||||||
and_(
|
|
||||||
Exchange.slot_start >= date_start,
|
|
||||||
Exchange.slot_start <= date_end,
|
|
||||||
Exchange.status == ExchangeStatus.BOOKED,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
booked_starts = {row[0] for row in result.all()}
|
|
||||||
|
|
||||||
# Expand each availability into slots
|
|
||||||
all_slots: list[BookableSlot] = []
|
|
||||||
for avail in availabilities:
|
|
||||||
slots = _expand_availability_to_slots(avail, date_param, booked_starts)
|
|
||||||
all_slots.extend(slots)
|
|
||||||
|
|
||||||
# Sort by start time
|
|
||||||
all_slots.sort(key=lambda s: s.start_time)
|
|
||||||
|
|
||||||
return AvailableSlotsResponse(date=date_param, slots=all_slots)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -247,21 +183,16 @@ async def create_exchange(
|
||||||
- EUR amount is within configured limits
|
- EUR amount is within configured limits
|
||||||
"""
|
"""
|
||||||
# Validate direction
|
# Validate direction
|
||||||
try:
|
direction: TradeDirection = validate_enum(
|
||||||
direction = TradeDirection(request.direction)
|
TradeDirection, request.direction, "direction"
|
||||||
except ValueError:
|
)
|
||||||
raise BadRequestError(
|
|
||||||
f"Invalid direction: {request.direction}. Must be 'buy' or 'sell'."
|
|
||||||
) from None
|
|
||||||
|
|
||||||
# Validate bitcoin transfer method
|
# Validate bitcoin transfer method
|
||||||
try:
|
bitcoin_transfer_method: BitcoinTransferMethod = validate_enum(
|
||||||
bitcoin_transfer_method = BitcoinTransferMethod(request.bitcoin_transfer_method)
|
BitcoinTransferMethod,
|
||||||
except ValueError:
|
request.bitcoin_transfer_method,
|
||||||
raise BadRequestError(
|
"bitcoin_transfer_method",
|
||||||
f"Invalid bitcoin_transfer_method: {request.bitcoin_transfer_method}. "
|
)
|
||||||
"Must be 'onchain' or 'lightning'."
|
|
||||||
) from None
|
|
||||||
|
|
||||||
# Use service to create exchange (handles all validation)
|
# Use service to create exchange (handles all validation)
|
||||||
service = ExchangeService(db)
|
service = ExchangeService(db)
|
||||||
|
|
@ -289,12 +220,8 @@ async def get_my_trades(
|
||||||
current_user: User = Depends(require_permission(Permission.VIEW_OWN_EXCHANGES)),
|
current_user: User = Depends(require_permission(Permission.VIEW_OWN_EXCHANGES)),
|
||||||
) -> list[ExchangeResponse]:
|
) -> list[ExchangeResponse]:
|
||||||
"""Get the current user's exchanges, sorted by date (newest first)."""
|
"""Get the current user's exchanges, sorted by date (newest first)."""
|
||||||
result = await db.execute(
|
exchange_repo = ExchangeRepository(db)
|
||||||
select(Exchange)
|
exchanges = await exchange_repo.get_by_user_id(current_user.id, order_by_desc=True)
|
||||||
.where(Exchange.user_id == current_user.id)
|
|
||||||
.order_by(Exchange.slot_start.desc())
|
|
||||||
)
|
|
||||||
exchanges = result.scalars().all()
|
|
||||||
|
|
||||||
return [ExchangeMapper.to_response(ex, current_user.email) for ex in exchanges]
|
return [ExchangeMapper.to_response(ex, current_user.email) for ex in exchanges]
|
||||||
|
|
||||||
|
|
@ -348,19 +275,8 @@ async def get_upcoming_trades(
|
||||||
_current_user: User = Depends(require_permission(Permission.VIEW_ALL_EXCHANGES)),
|
_current_user: User = Depends(require_permission(Permission.VIEW_ALL_EXCHANGES)),
|
||||||
) -> list[AdminExchangeResponse]:
|
) -> list[AdminExchangeResponse]:
|
||||||
"""Get all upcoming booked trades, sorted by slot time ascending."""
|
"""Get all upcoming booked trades, sorted by slot time ascending."""
|
||||||
now = datetime.now(UTC)
|
exchange_repo = ExchangeRepository(db)
|
||||||
result = await db.execute(
|
exchanges = await exchange_repo.get_upcoming_booked()
|
||||||
select(Exchange)
|
|
||||||
.options(joinedload(Exchange.user))
|
|
||||||
.where(
|
|
||||||
and_(
|
|
||||||
Exchange.slot_start > now,
|
|
||||||
Exchange.status == ExchangeStatus.BOOKED,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.order_by(Exchange.slot_start.asc())
|
|
||||||
)
|
|
||||||
exchanges = result.scalars().all()
|
|
||||||
|
|
||||||
return [ExchangeMapper.to_admin_response(ex) for ex in exchanges]
|
return [ExchangeMapper.to_admin_response(ex) for ex in exchanges]
|
||||||
|
|
||||||
|
|
@ -383,45 +299,19 @@ async def get_past_trades(
|
||||||
- user_search: Search by user email (partial match)
|
- user_search: Search by user email (partial match)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
|
|
||||||
# Start with base query for past trades (slot_start <= now OR not booked)
|
|
||||||
query = (
|
|
||||||
select(Exchange)
|
|
||||||
.options(joinedload(Exchange.user))
|
|
||||||
.where(
|
|
||||||
(Exchange.slot_start <= now) | (Exchange.status != ExchangeStatus.BOOKED)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply status filter
|
# Apply status filter
|
||||||
|
status_enum: ExchangeStatus | None = None
|
||||||
if status:
|
if status:
|
||||||
try:
|
status_enum = validate_enum(ExchangeStatus, status, "status")
|
||||||
status_enum = ExchangeStatus(status)
|
|
||||||
query = query.where(Exchange.status == status_enum)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Invalid status: {status}",
|
|
||||||
) from None
|
|
||||||
|
|
||||||
# Apply date range filter
|
# Use repository for query
|
||||||
if start_date:
|
exchange_repo = ExchangeRepository(db)
|
||||||
start_dt = datetime.combine(start_date, time.min, tzinfo=UTC)
|
exchanges = await exchange_repo.get_past_trades(
|
||||||
query = query.where(Exchange.slot_start >= start_dt)
|
status=status_enum,
|
||||||
if end_date:
|
start_date=start_date,
|
||||||
end_dt = datetime.combine(end_date, time.max, tzinfo=UTC)
|
end_date=end_date,
|
||||||
query = query.where(Exchange.slot_start <= end_dt)
|
user_search=user_search,
|
||||||
|
)
|
||||||
# Apply user search filter (join with User table)
|
|
||||||
if user_search:
|
|
||||||
query = query.join(Exchange.user).where(User.email.ilike(f"%{user_search}%"))
|
|
||||||
|
|
||||||
# Order by most recent first
|
|
||||||
query = query.order_by(Exchange.slot_start.desc())
|
|
||||||
|
|
||||||
result = await db.execute(query)
|
|
||||||
exchanges = result.scalars().all()
|
|
||||||
|
|
||||||
return [ExchangeMapper.to_admin_response(ex) for ex in exchanges]
|
return [ExchangeMapper.to_admin_response(ex) for ex in exchanges]
|
||||||
|
|
||||||
|
|
@ -487,6 +377,10 @@ async def search_users(
|
||||||
Returns users whose email contains the search query (case-insensitive).
|
Returns users whose email contains the search query (case-insensitive).
|
||||||
Limited to 10 results for autocomplete purposes.
|
Limited to 10 results for autocomplete purposes.
|
||||||
"""
|
"""
|
||||||
|
# Note: UserRepository doesn't have search yet, but we can add it
|
||||||
|
# For now, keeping direct query for this specific use case
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(User).where(User.email.ilike(f"%{q}%")).order_by(User.email).limit(10)
|
select(User).where(User.email.ilike(f"%{q}%")).order_by(User.email).limit(10)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,13 @@
|
||||||
"""Invite routes for public check, user invites, and admin management."""
|
"""Invite routes for public check, user invites, and admin management."""
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import select
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
||||||
from sqlalchemy import desc, func, select
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from auth import require_permission
|
from auth import require_permission
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from exceptions import BadRequestError, NotFoundError
|
|
||||||
from invite_utils import (
|
|
||||||
generate_invite_identifier,
|
|
||||||
is_valid_identifier_format,
|
|
||||||
normalize_identifier,
|
|
||||||
)
|
|
||||||
from mappers import InviteMapper
|
from mappers import InviteMapper
|
||||||
from models import Invite, InviteStatus, Permission, User
|
from models import Permission, User
|
||||||
from pagination import calculate_offset, create_paginated_response
|
|
||||||
from schemas import (
|
from schemas import (
|
||||||
AdminUserResponse,
|
AdminUserResponse,
|
||||||
InviteCheckResponse,
|
InviteCheckResponse,
|
||||||
|
|
@ -26,12 +16,11 @@ from schemas import (
|
||||||
PaginatedInviteRecords,
|
PaginatedInviteRecords,
|
||||||
UserInviteResponse,
|
UserInviteResponse,
|
||||||
)
|
)
|
||||||
|
from services.invite import InviteService
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/invites", tags=["invites"])
|
router = APIRouter(prefix="/api/invites", tags=["invites"])
|
||||||
admin_router = APIRouter(prefix="/api/admin", tags=["admin"])
|
admin_router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||||
|
|
||||||
MAX_INVITE_COLLISION_RETRIES = 3
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{identifier}/check", response_model=InviteCheckResponse)
|
@router.get("/{identifier}/check", response_model=InviteCheckResponse)
|
||||||
async def check_invite(
|
async def check_invite(
|
||||||
|
|
@ -39,20 +28,8 @@ async def check_invite(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> InviteCheckResponse:
|
) -> InviteCheckResponse:
|
||||||
"""Check if an invite is valid and can be used for signup."""
|
"""Check if an invite is valid and can be used for signup."""
|
||||||
normalized = normalize_identifier(identifier)
|
service = InviteService(db)
|
||||||
|
return await service.check_invite_validity(identifier)
|
||||||
# Validate format before querying database
|
|
||||||
if not is_valid_identifier_format(normalized):
|
|
||||||
return InviteCheckResponse(valid=False, error="Invalid invite code format")
|
|
||||||
|
|
||||||
result = await db.execute(select(Invite).where(Invite.identifier == normalized))
|
|
||||||
invite = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
# Return same error for not found, spent, and revoked to avoid information leakage
|
|
||||||
if not invite or invite.status in (InviteStatus.SPENT, InviteStatus.REVOKED):
|
|
||||||
return InviteCheckResponse(valid=False, error="Invite not found")
|
|
||||||
|
|
||||||
return InviteCheckResponse(valid=True, status=invite.status.value)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[UserInviteResponse])
|
@router.get("", response_model=list[UserInviteResponse])
|
||||||
|
|
@ -61,14 +38,9 @@ async def get_my_invites(
|
||||||
current_user: User = Depends(require_permission(Permission.VIEW_OWN_INVITES)),
|
current_user: User = Depends(require_permission(Permission.VIEW_OWN_INVITES)),
|
||||||
) -> list[UserInviteResponse]:
|
) -> list[UserInviteResponse]:
|
||||||
"""Get all invites owned by the current user."""
|
"""Get all invites owned by the current user."""
|
||||||
result = await db.execute(
|
service = InviteService(db)
|
||||||
select(Invite)
|
invites = await service.get_user_invites(current_user.id)
|
||||||
.where(Invite.godfather_id == current_user.id)
|
|
||||||
.order_by(desc(Invite.created_at))
|
|
||||||
)
|
|
||||||
invites = result.scalars().all()
|
|
||||||
|
|
||||||
# Use preloaded used_by relationship (selectin loading)
|
|
||||||
return [
|
return [
|
||||||
UserInviteResponse(
|
UserInviteResponse(
|
||||||
id=invite.id,
|
id=invite.id,
|
||||||
|
|
@ -88,6 +60,8 @@ async def list_users_for_admin(
|
||||||
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
|
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
|
||||||
) -> list[AdminUserResponse]:
|
) -> list[AdminUserResponse]:
|
||||||
"""List all users for admin dropdowns (invite creation, etc.)."""
|
"""List all users for admin dropdowns (invite creation, etc.)."""
|
||||||
|
# Note: UserRepository doesn't have list_all yet
|
||||||
|
# For now, keeping direct query for this specific use case
|
||||||
result = await db.execute(select(User.id, User.email).order_by(User.email))
|
result = await db.execute(select(User.id, User.email).order_by(User.email))
|
||||||
users = result.all()
|
users = result.all()
|
||||||
return [AdminUserResponse(id=u.id, email=u.email) for u in users]
|
return [AdminUserResponse(id=u.id, email=u.email) for u in users]
|
||||||
|
|
@ -100,39 +74,8 @@ async def create_invite(
|
||||||
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
|
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
|
||||||
) -> InviteResponse:
|
) -> InviteResponse:
|
||||||
"""Create a new invite for a specified godfather user."""
|
"""Create a new invite for a specified godfather user."""
|
||||||
# Validate godfather exists
|
service = InviteService(db)
|
||||||
result = await db.execute(select(User.id).where(User.id == data.godfather_id))
|
invite = await service.create_invite(data.godfather_id)
|
||||||
godfather_id = result.scalar_one_or_none()
|
|
||||||
if not godfather_id:
|
|
||||||
raise BadRequestError("Godfather user not found")
|
|
||||||
|
|
||||||
# Try to create invite with retry on collision
|
|
||||||
invite: Invite | None = None
|
|
||||||
for attempt in range(MAX_INVITE_COLLISION_RETRIES):
|
|
||||||
identifier = generate_invite_identifier()
|
|
||||||
invite = Invite(
|
|
||||||
identifier=identifier,
|
|
||||||
godfather_id=godfather_id,
|
|
||||||
status=InviteStatus.READY,
|
|
||||||
)
|
|
||||||
db.add(invite)
|
|
||||||
try:
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(invite, ["godfather"])
|
|
||||||
break
|
|
||||||
except IntegrityError:
|
|
||||||
await db.rollback()
|
|
||||||
if attempt == MAX_INVITE_COLLISION_RETRIES - 1:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Failed to generate unique invite code. Try again.",
|
|
||||||
) from None
|
|
||||||
|
|
||||||
if invite is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
||||||
detail="Failed to create invite",
|
|
||||||
)
|
|
||||||
return InviteMapper.to_response(invite)
|
return InviteMapper.to_response(invite)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -148,41 +91,13 @@ async def list_all_invites(
|
||||||
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
|
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
|
||||||
) -> PaginatedInviteRecords:
|
) -> PaginatedInviteRecords:
|
||||||
"""List all invites with optional filtering and pagination."""
|
"""List all invites with optional filtering and pagination."""
|
||||||
# Build query
|
service = InviteService(db)
|
||||||
query = select(Invite)
|
return await service.list_invites(
|
||||||
count_query = select(func.count(Invite.id))
|
page=page,
|
||||||
|
per_page=per_page,
|
||||||
# Apply filters
|
status_filter=status_filter,
|
||||||
if status_filter:
|
godfather_id=godfather_id,
|
||||||
try:
|
)
|
||||||
status_enum = InviteStatus(status_filter)
|
|
||||||
query = query.where(Invite.status == status_enum)
|
|
||||||
count_query = count_query.where(Invite.status == status_enum)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Invalid status: {status_filter}. "
|
|
||||||
"Must be ready, spent, or revoked",
|
|
||||||
) from None
|
|
||||||
|
|
||||||
if godfather_id:
|
|
||||||
query = query.where(Invite.godfather_id == godfather_id)
|
|
||||||
count_query = count_query.where(Invite.godfather_id == godfather_id)
|
|
||||||
|
|
||||||
# Get total count
|
|
||||||
count_result = await db.execute(count_query)
|
|
||||||
total = count_result.scalar() or 0
|
|
||||||
|
|
||||||
# Get paginated invites (relationships loaded via selectin)
|
|
||||||
offset = calculate_offset(page, per_page)
|
|
||||||
query = query.order_by(desc(Invite.created_at)).offset(offset).limit(per_page)
|
|
||||||
result = await db.execute(query)
|
|
||||||
invites = result.scalars().all()
|
|
||||||
|
|
||||||
# Build responses using preloaded relationships
|
|
||||||
records = [InviteMapper.to_response(invite) for invite in invites]
|
|
||||||
|
|
||||||
return create_paginated_response(records, total, page, per_page)
|
|
||||||
|
|
||||||
|
|
||||||
@admin_router.post("/invites/{invite_id}/revoke", response_model=InviteResponse)
|
@admin_router.post("/invites/{invite_id}/revoke", response_model=InviteResponse)
|
||||||
|
|
@ -192,23 +107,8 @@ async def revoke_invite(
|
||||||
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
|
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
|
||||||
) -> InviteResponse:
|
) -> InviteResponse:
|
||||||
"""Revoke an invite. Only READY invites can be revoked."""
|
"""Revoke an invite. Only READY invites can be revoked."""
|
||||||
result = await db.execute(select(Invite).where(Invite.id == invite_id))
|
service = InviteService(db)
|
||||||
invite = result.scalar_one_or_none()
|
invite = await service.revoke_invite(invite_id)
|
||||||
|
|
||||||
if not invite:
|
|
||||||
raise NotFoundError("Invite")
|
|
||||||
|
|
||||||
if invite.status != InviteStatus.READY:
|
|
||||||
raise BadRequestError(
|
|
||||||
f"Cannot revoke invite with status '{invite.status.value}'. "
|
|
||||||
"Only READY invites can be revoked."
|
|
||||||
)
|
|
||||||
|
|
||||||
invite.status = InviteStatus.REVOKED
|
|
||||||
invite.revoked_at = datetime.now(UTC)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(invite)
|
|
||||||
|
|
||||||
return InviteMapper.to_response(invite)
|
return InviteMapper.to_response(invite)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,25 @@
|
||||||
"""Profile routes for user contact details."""
|
"""Profile routes for user contact details."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from auth import require_permission
|
from auth import require_permission
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import Permission, User
|
from models import Permission, User
|
||||||
from schemas import ProfileResponse, ProfileUpdate
|
from schemas import ProfileResponse, ProfileUpdate
|
||||||
from validation import validate_profile_fields
|
from services.profile import ProfileService
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/profile", tags=["profile"])
|
router = APIRouter(prefix="/api/profile", tags=["profile"])
|
||||||
|
|
||||||
|
|
||||||
async def get_godfather_email(db: AsyncSession, godfather_id: int | None) -> str | None:
|
|
||||||
"""Get the email of a godfather user by ID."""
|
|
||||||
if not godfather_id:
|
|
||||||
return None
|
|
||||||
result = await db.execute(select(User.email).where(User.id == godfather_id))
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=ProfileResponse)
|
@router.get("", response_model=ProfileResponse)
|
||||||
async def get_profile(
|
async def get_profile(
|
||||||
current_user: User = Depends(require_permission(Permission.MANAGE_OWN_PROFILE)),
|
current_user: User = Depends(require_permission(Permission.MANAGE_OWN_PROFILE)),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> ProfileResponse:
|
) -> ProfileResponse:
|
||||||
"""Get the current user's profile (contact details and godfather)."""
|
"""Get the current user's profile (contact details and godfather)."""
|
||||||
godfather_email = await get_godfather_email(db, current_user.godfather_id)
|
service = ProfileService(db)
|
||||||
|
return await service.get_profile(current_user)
|
||||||
return ProfileResponse(
|
|
||||||
contact_email=current_user.contact_email,
|
|
||||||
telegram=current_user.telegram,
|
|
||||||
signal=current_user.signal,
|
|
||||||
nostr_npub=current_user.nostr_npub,
|
|
||||||
godfather_email=godfather_email,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("", response_model=ProfileResponse)
|
@router.put("", response_model=ProfileResponse)
|
||||||
|
|
@ -45,36 +29,5 @@ async def update_profile(
|
||||||
current_user: User = Depends(require_permission(Permission.MANAGE_OWN_PROFILE)),
|
current_user: User = Depends(require_permission(Permission.MANAGE_OWN_PROFILE)),
|
||||||
) -> ProfileResponse:
|
) -> ProfileResponse:
|
||||||
"""Update the current user's profile (contact details)."""
|
"""Update the current user's profile (contact details)."""
|
||||||
# Validate all fields
|
service = ProfileService(db)
|
||||||
errors = validate_profile_fields(
|
return await service.update_profile(current_user, data)
|
||||||
contact_email=data.contact_email,
|
|
||||||
telegram=data.telegram,
|
|
||||||
signal=data.signal,
|
|
||||||
nostr_npub=data.nostr_npub,
|
|
||||||
)
|
|
||||||
|
|
||||||
if errors:
|
|
||||||
# Keep field_errors format for backward compatibility with frontend
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail={"field_errors": errors},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update fields
|
|
||||||
current_user.contact_email = data.contact_email
|
|
||||||
current_user.telegram = data.telegram
|
|
||||||
current_user.signal = data.signal
|
|
||||||
current_user.nostr_npub = data.nostr_npub
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(current_user)
|
|
||||||
|
|
||||||
godfather_email = await get_godfather_email(db, current_user.godfather_id)
|
|
||||||
|
|
||||||
return ProfileResponse(
|
|
||||||
contact_email=current_user.contact_email,
|
|
||||||
telegram=current_user.telegram,
|
|
||||||
signal=current_user.signal,
|
|
||||||
nostr_npub=current_user.nostr_npub,
|
|
||||||
godfather_email=godfather_email,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
115
backend/services/auth.py
Normal file
115
backend/services/auth.py
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
"""Authentication service for user registration and login."""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from auth import (
|
||||||
|
create_access_token,
|
||||||
|
get_password_hash,
|
||||||
|
)
|
||||||
|
from exceptions import BadRequestError, UnauthorizedError
|
||||||
|
from invite_utils import normalize_identifier
|
||||||
|
from models import ROLE_REGULAR, InviteStatus, User
|
||||||
|
from repositories.invite import InviteRepository
|
||||||
|
from repositories.role import RoleRepository
|
||||||
|
from repositories.user import UserRepository
|
||||||
|
|
||||||
|
|
||||||
|
class AuthService:
|
||||||
|
"""Service for authentication-related business logic."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
self.user_repo = UserRepository(db)
|
||||||
|
self.invite_repo = InviteRepository(db)
|
||||||
|
self.role_repo = RoleRepository(db)
|
||||||
|
|
||||||
|
async def register_user(
|
||||||
|
self, email: str, password: str, invite_identifier: str
|
||||||
|
) -> tuple[User, str]:
|
||||||
|
"""
|
||||||
|
Register a new user using an invite code.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: User email address
|
||||||
|
password: Plain text password (will be hashed)
|
||||||
|
invite_identifier: Invite code identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (User, access_token)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BadRequestError: If invite is invalid, email already taken,
|
||||||
|
or other validation fails
|
||||||
|
"""
|
||||||
|
# Validate invite
|
||||||
|
normalized_identifier = normalize_identifier(invite_identifier)
|
||||||
|
invite = await self.invite_repo.get_by_identifier(normalized_identifier)
|
||||||
|
|
||||||
|
# Return same error for not found, spent, and revoked
|
||||||
|
# to avoid information leakage
|
||||||
|
if not invite or invite.status in (
|
||||||
|
InviteStatus.SPENT,
|
||||||
|
InviteStatus.REVOKED,
|
||||||
|
):
|
||||||
|
raise BadRequestError("Invalid invite code")
|
||||||
|
|
||||||
|
# Check email not already taken
|
||||||
|
existing_user = await self.user_repo.get_by_email(email)
|
||||||
|
if existing_user:
|
||||||
|
raise BadRequestError("Email already registered")
|
||||||
|
|
||||||
|
# Create user with godfather
|
||||||
|
user = User(
|
||||||
|
email=email,
|
||||||
|
hashed_password=get_password_hash(password),
|
||||||
|
godfather_id=invite.godfather_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assign default role
|
||||||
|
default_role = await self.role_repo.get_by_name(ROLE_REGULAR)
|
||||||
|
if default_role:
|
||||||
|
user.roles.append(default_role)
|
||||||
|
|
||||||
|
# Create user (flush to get ID)
|
||||||
|
user = await self.user_repo.create(user)
|
||||||
|
|
||||||
|
# Mark invite as spent
|
||||||
|
invite.status = InviteStatus.SPENT
|
||||||
|
invite.used_by_id = user.id
|
||||||
|
invite.spent_at = datetime.now(UTC)
|
||||||
|
await self.invite_repo.update(invite)
|
||||||
|
|
||||||
|
# Refresh user to ensure it's up to date
|
||||||
|
await self.user_repo.refresh(user)
|
||||||
|
|
||||||
|
# Create access token
|
||||||
|
access_token = create_access_token(data={"sub": str(user.id)})
|
||||||
|
|
||||||
|
return user, access_token
|
||||||
|
|
||||||
|
async def login_user(self, email: str, password: str) -> tuple[User, str]:
|
||||||
|
"""
|
||||||
|
Authenticate a user and create access token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: User email address
|
||||||
|
password: Plain text password
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (User, access_token)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BadRequestError: If authentication fails
|
||||||
|
"""
|
||||||
|
from auth import authenticate_user
|
||||||
|
|
||||||
|
user = await authenticate_user(self.db, email, password)
|
||||||
|
if not user:
|
||||||
|
raise UnauthorizedError("Incorrect email or password")
|
||||||
|
|
||||||
|
# Create access token
|
||||||
|
access_token = create_access_token(data={"sub": str(user.id)})
|
||||||
|
|
||||||
|
return user, access_token
|
||||||
197
backend/services/availability.py
Normal file
197
backend/services/availability.py
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
"""Availability service for managing booking availability."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
# Import for validation
|
||||||
|
from date_validation import validate_date_in_range
|
||||||
|
from exceptions import BadRequestError
|
||||||
|
from models import Availability
|
||||||
|
from repositories.availability import AvailabilityRepository
|
||||||
|
from schemas import AvailabilityDay, AvailabilityResponse, TimeSlot
|
||||||
|
|
||||||
|
|
||||||
|
class AvailabilityService:
|
||||||
|
"""Service for availability-related business logic."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
self.availability_repo = AvailabilityRepository(db)
|
||||||
|
|
||||||
|
async def get_availability_for_range(
|
||||||
|
self, from_date: date, to_date: date
|
||||||
|
) -> AvailabilityResponse:
|
||||||
|
"""
|
||||||
|
Get availability slots for a date range, grouped by date.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
from_date: Start date (inclusive)
|
||||||
|
to_date: End date (inclusive)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AvailabilityResponse with days grouped and sorted
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BadRequestError: If from_date > to_date
|
||||||
|
"""
|
||||||
|
if from_date > to_date:
|
||||||
|
raise BadRequestError("'from' date must be before or equal to 'to' date")
|
||||||
|
|
||||||
|
# Query availability in range
|
||||||
|
slots = await self.availability_repo.get_by_date_range(from_date, to_date)
|
||||||
|
|
||||||
|
# Group by date
|
||||||
|
days_dict: dict[date, list[TimeSlot]] = {}
|
||||||
|
for slot in slots:
|
||||||
|
if slot.date not in days_dict:
|
||||||
|
days_dict[slot.date] = []
|
||||||
|
days_dict[slot.date].append(
|
||||||
|
TimeSlot(start_time=slot.start_time, end_time=slot.end_time)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert to response format
|
||||||
|
days = [
|
||||||
|
AvailabilityDay(date=d, slots=days_dict[d])
|
||||||
|
for d in sorted(days_dict.keys())
|
||||||
|
]
|
||||||
|
|
||||||
|
return AvailabilityResponse(days=days)
|
||||||
|
|
||||||
|
def _validate_slots(self, slots: list[TimeSlot]) -> None:
|
||||||
|
"""
|
||||||
|
Validate that slots don't overlap and have valid time ordering.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BadRequestError: If validation fails
|
||||||
|
"""
|
||||||
|
# Validate slots don't overlap
|
||||||
|
sorted_slots = sorted(slots, key=lambda s: s.start_time)
|
||||||
|
for i in range(len(sorted_slots) - 1):
|
||||||
|
if sorted_slots[i].end_time > sorted_slots[i + 1].start_time:
|
||||||
|
end = sorted_slots[i].end_time
|
||||||
|
start = sorted_slots[i + 1].start_time
|
||||||
|
raise BadRequestError(
|
||||||
|
f"Time slots overlap: slot ending at {end} "
|
||||||
|
f"overlaps with slot starting at {start}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate each slot's end_time > start_time
|
||||||
|
for slot in slots:
|
||||||
|
if slot.end_time <= slot.start_time:
|
||||||
|
raise BadRequestError(
|
||||||
|
f"Invalid time slot: end time {slot.end_time} "
|
||||||
|
f"must be after start time {slot.start_time}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_availability_for_date(
|
||||||
|
self, target_date: date, slots: list[TimeSlot]
|
||||||
|
) -> AvailabilityDay:
|
||||||
|
"""
|
||||||
|
Set availability for a specific date. Replaces any existing availability.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target_date: Date to set availability for
|
||||||
|
slots: List of time slots for the date
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AvailabilityDay with the set slots
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BadRequestError: If date is invalid or slots are invalid
|
||||||
|
"""
|
||||||
|
validate_date_in_range(target_date, context="set availability")
|
||||||
|
|
||||||
|
# Validate slots
|
||||||
|
self._validate_slots(slots)
|
||||||
|
|
||||||
|
# Delete existing availability for this date
|
||||||
|
await self.availability_repo.delete_by_date(target_date)
|
||||||
|
|
||||||
|
# Create new availability slots
|
||||||
|
availabilities = [
|
||||||
|
Availability(
|
||||||
|
date=target_date,
|
||||||
|
start_time=slot.start_time,
|
||||||
|
end_time=slot.end_time,
|
||||||
|
)
|
||||||
|
for slot in slots
|
||||||
|
]
|
||||||
|
await self.availability_repo.create_multiple(availabilities)
|
||||||
|
await self.availability_repo.commit()
|
||||||
|
|
||||||
|
return AvailabilityDay(date=target_date, slots=slots)
|
||||||
|
|
||||||
|
async def copy_availability(
|
||||||
|
self, source_date: date, target_dates: list[date]
|
||||||
|
) -> AvailabilityResponse:
|
||||||
|
"""
|
||||||
|
Copy availability from one day to multiple target days.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_date: Date to copy availability from
|
||||||
|
target_dates: List of dates to copy availability to
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AvailabilityResponse with copied days
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BadRequestError: If source date has no availability or dates are invalid
|
||||||
|
"""
|
||||||
|
# Validate source date is in range
|
||||||
|
validate_date_in_range(source_date, context="copy from")
|
||||||
|
|
||||||
|
# Validate target dates
|
||||||
|
for target_date in target_dates:
|
||||||
|
validate_date_in_range(target_date, context="copy to")
|
||||||
|
|
||||||
|
# Get source availability
|
||||||
|
source_slots = await self.availability_repo.get_by_date(source_date)
|
||||||
|
|
||||||
|
if not source_slots:
|
||||||
|
raise BadRequestError(
|
||||||
|
f"No availability found for source date {source_date}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Copy to each target date within a single atomic transaction
|
||||||
|
# All deletes and inserts happen before commit, ensuring atomicity
|
||||||
|
copied_days: list[AvailabilityDay] = []
|
||||||
|
try:
|
||||||
|
for target_date in target_dates:
|
||||||
|
if target_date == source_date:
|
||||||
|
continue # Skip copying to self
|
||||||
|
|
||||||
|
# Delete existing availability for target date
|
||||||
|
await self.availability_repo.delete_by_date(target_date)
|
||||||
|
|
||||||
|
# Copy slots
|
||||||
|
target_slots: list[TimeSlot] = []
|
||||||
|
new_availabilities = [
|
||||||
|
Availability(
|
||||||
|
date=target_date,
|
||||||
|
start_time=source_slot.start_time,
|
||||||
|
end_time=source_slot.end_time,
|
||||||
|
)
|
||||||
|
for source_slot in source_slots
|
||||||
|
]
|
||||||
|
await self.availability_repo.create_multiple(new_availabilities)
|
||||||
|
target_slots = [
|
||||||
|
TimeSlot(
|
||||||
|
start_time=slot.start_time,
|
||||||
|
end_time=slot.end_time,
|
||||||
|
)
|
||||||
|
for slot in source_slots
|
||||||
|
]
|
||||||
|
|
||||||
|
copied_days.append(
|
||||||
|
AvailabilityDay(date=target_date, slots=target_slots)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Commit all changes atomically
|
||||||
|
await self.availability_repo.commit()
|
||||||
|
except Exception:
|
||||||
|
# Rollback on any error to maintain atomicity
|
||||||
|
await self.db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
return AvailabilityResponse(days=copied_days)
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
"""Exchange service for business logic related to Bitcoin trading."""
|
"""Exchange service for business logic related to Bitcoin trading."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, date, datetime, time, timedelta
|
from datetime import UTC, date, datetime, timedelta
|
||||||
|
|
||||||
from sqlalchemy import and_, select
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
@ -23,7 +22,10 @@ from models import (
|
||||||
TradeDirection,
|
TradeDirection,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
|
from repositories.availability import AvailabilityRepository
|
||||||
|
from repositories.exchange import ExchangeRepository
|
||||||
from repositories.price import PriceRepository
|
from repositories.price import PriceRepository
|
||||||
|
from schemas import AvailableSlotsResponse, BookableSlot
|
||||||
from shared_constants import (
|
from shared_constants import (
|
||||||
EUR_TRADE_INCREMENT,
|
EUR_TRADE_INCREMENT,
|
||||||
EUR_TRADE_MAX,
|
EUR_TRADE_MAX,
|
||||||
|
|
@ -44,6 +46,8 @@ class ExchangeService:
|
||||||
def __init__(self, db: AsyncSession):
|
def __init__(self, db: AsyncSession):
|
||||||
self.db = db
|
self.db = db
|
||||||
self.price_repo = PriceRepository(db)
|
self.price_repo = PriceRepository(db)
|
||||||
|
self.exchange_repo = ExchangeRepository(db)
|
||||||
|
self.availability_repo = AvailabilityRepository(db)
|
||||||
|
|
||||||
def apply_premium_for_direction(
|
def apply_premium_for_direction(
|
||||||
self,
|
self,
|
||||||
|
|
@ -107,20 +111,21 @@ class ExchangeService:
|
||||||
self, slot_start: datetime, slot_date: date
|
self, slot_start: datetime, slot_date: date
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Verify slot falls within availability."""
|
"""Verify slot falls within availability."""
|
||||||
|
from repositories.availability import AvailabilityRepository
|
||||||
|
|
||||||
slot_start_time = slot_start.time()
|
slot_start_time = slot_start.time()
|
||||||
slot_end_dt = slot_start + timedelta(minutes=SLOT_DURATION_MINUTES)
|
slot_end_dt = slot_start + timedelta(minutes=SLOT_DURATION_MINUTES)
|
||||||
slot_end_time = slot_end_dt.time()
|
slot_end_time = slot_end_dt.time()
|
||||||
|
|
||||||
result = await self.db.execute(
|
availability_repo = AvailabilityRepository(self.db)
|
||||||
select(Availability).where(
|
availabilities = await availability_repo.get_by_date(slot_date)
|
||||||
and_(
|
|
||||||
Availability.date == slot_date,
|
# Check if any availability block contains this slot
|
||||||
Availability.start_time <= slot_start_time,
|
matching_availability = None
|
||||||
Availability.end_time >= slot_end_time,
|
for avail in availabilities:
|
||||||
)
|
if avail.start_time <= slot_start_time and avail.end_time >= slot_end_time:
|
||||||
)
|
matching_availability = avail
|
||||||
)
|
break
|
||||||
matching_availability = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not matching_availability:
|
if not matching_availability:
|
||||||
slot_str = slot_start.strftime("%Y-%m-%d %H:%M")
|
slot_str = slot_start.strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
@ -171,29 +176,19 @@ class ExchangeService:
|
||||||
self, user: User, slot_date: date
|
self, user: User, slot_date: date
|
||||||
) -> Exchange | None:
|
) -> Exchange | None:
|
||||||
"""Check if user already has a trade on this date."""
|
"""Check if user already has a trade on this date."""
|
||||||
existing_trade_query = select(Exchange).where(
|
exchanges = await self.exchange_repo.get_by_user_and_date_range(
|
||||||
and_(
|
user_id=user.id,
|
||||||
Exchange.user_id == user.id,
|
start_date=slot_date,
|
||||||
Exchange.slot_start
|
end_date=slot_date,
|
||||||
>= datetime.combine(slot_date, time.min, tzinfo=UTC),
|
status=ExchangeStatus.BOOKED,
|
||||||
Exchange.slot_start
|
|
||||||
< datetime.combine(slot_date, time.max, tzinfo=UTC) + timedelta(days=1),
|
|
||||||
Exchange.status == ExchangeStatus.BOOKED,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
result = await self.db.execute(existing_trade_query)
|
return exchanges[0] if exchanges else None
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
async def check_slot_already_booked(self, slot_start: datetime) -> Exchange | None:
|
async def check_slot_already_booked(self, slot_start: datetime) -> Exchange | None:
|
||||||
"""Check if slot is already booked (only consider BOOKED status)."""
|
"""Check if slot is already booked (only consider BOOKED status)."""
|
||||||
slot_booked_query = select(Exchange).where(
|
return await self.exchange_repo.get_by_slot_start(
|
||||||
and_(
|
slot_start, status=ExchangeStatus.BOOKED
|
||||||
Exchange.slot_start == slot_start,
|
|
||||||
Exchange.status == ExchangeStatus.BOOKED,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
result = await self.db.execute(slot_booked_query)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
async def create_exchange(
|
async def create_exchange(
|
||||||
self,
|
self,
|
||||||
|
|
@ -272,11 +267,8 @@ class ExchangeService:
|
||||||
status=ExchangeStatus.BOOKED,
|
status=ExchangeStatus.BOOKED,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.db.add(exchange)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self.db.commit()
|
return await self.exchange_repo.create(exchange)
|
||||||
await self.db.refresh(exchange)
|
|
||||||
except IntegrityError as e:
|
except IntegrityError as e:
|
||||||
await self.db.rollback()
|
await self.db.rollback()
|
||||||
# This should rarely happen now since we check explicitly above,
|
# This should rarely happen now since we check explicitly above,
|
||||||
|
|
@ -285,8 +277,6 @@ class ExchangeService:
|
||||||
"Database constraint violation. Please try again."
|
"Database constraint violation. Please try again."
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
return exchange
|
|
||||||
|
|
||||||
async def get_exchange_by_public_id(
|
async def get_exchange_by_public_id(
|
||||||
self, public_id: uuid.UUID, user: User | None = None
|
self, public_id: uuid.UUID, user: User | None = None
|
||||||
) -> Exchange:
|
) -> Exchange:
|
||||||
|
|
@ -297,9 +287,7 @@ class ExchangeService:
|
||||||
NotFoundError: If exchange not found or user doesn't own it
|
NotFoundError: If exchange not found or user doesn't own it
|
||||||
(for security, returns 404)
|
(for security, returns 404)
|
||||||
"""
|
"""
|
||||||
query = select(Exchange).where(Exchange.public_id == public_id)
|
exchange = await self.exchange_repo.get_by_public_id(public_id)
|
||||||
result = await self.db.execute(query)
|
|
||||||
exchange = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not exchange:
|
if not exchange:
|
||||||
raise NotFoundError("Trade")
|
raise NotFoundError("Trade")
|
||||||
|
|
@ -338,10 +326,7 @@ class ExchangeService:
|
||||||
)
|
)
|
||||||
exchange.cancelled_at = datetime.now(UTC)
|
exchange.cancelled_at = datetime.now(UTC)
|
||||||
|
|
||||||
await self.db.commit()
|
return await self.exchange_repo.update(exchange)
|
||||||
await self.db.refresh(exchange)
|
|
||||||
|
|
||||||
return exchange
|
|
||||||
|
|
||||||
async def complete_exchange(self, exchange: Exchange) -> Exchange:
|
async def complete_exchange(self, exchange: Exchange) -> Exchange:
|
||||||
"""
|
"""
|
||||||
|
|
@ -361,10 +346,7 @@ class ExchangeService:
|
||||||
exchange.status = ExchangeStatus.COMPLETED
|
exchange.status = ExchangeStatus.COMPLETED
|
||||||
exchange.completed_at = datetime.now(UTC)
|
exchange.completed_at = datetime.now(UTC)
|
||||||
|
|
||||||
await self.db.commit()
|
return await self.exchange_repo.update(exchange)
|
||||||
await self.db.refresh(exchange)
|
|
||||||
|
|
||||||
return exchange
|
|
||||||
|
|
||||||
async def mark_no_show(self, exchange: Exchange) -> Exchange:
|
async def mark_no_show(self, exchange: Exchange) -> Exchange:
|
||||||
"""
|
"""
|
||||||
|
|
@ -386,7 +368,74 @@ class ExchangeService:
|
||||||
exchange.status = ExchangeStatus.NO_SHOW
|
exchange.status = ExchangeStatus.NO_SHOW
|
||||||
exchange.completed_at = datetime.now(UTC)
|
exchange.completed_at = datetime.now(UTC)
|
||||||
|
|
||||||
await self.db.commit()
|
return await self.exchange_repo.update(exchange)
|
||||||
await self.db.refresh(exchange)
|
|
||||||
|
|
||||||
return exchange
|
def _expand_availability_to_slots(
|
||||||
|
self, avail: Availability, slot_date: date, booked_starts: set[datetime]
|
||||||
|
) -> list[BookableSlot]:
|
||||||
|
"""
|
||||||
|
Expand an availability block into individual slots, filtering out booked ones.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
avail: Availability record
|
||||||
|
slot_date: Date for the slots
|
||||||
|
booked_starts: Set of already-booked slot start times
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of available BookableSlot records
|
||||||
|
"""
|
||||||
|
slots: list[BookableSlot] = []
|
||||||
|
|
||||||
|
# Start from the availability's start time
|
||||||
|
current_start = datetime.combine(slot_date, avail.start_time, tzinfo=UTC)
|
||||||
|
avail_end = datetime.combine(slot_date, avail.end_time, tzinfo=UTC)
|
||||||
|
|
||||||
|
while current_start + timedelta(minutes=SLOT_DURATION_MINUTES) <= avail_end:
|
||||||
|
slot_end = current_start + timedelta(minutes=SLOT_DURATION_MINUTES)
|
||||||
|
|
||||||
|
# Only include if not already booked
|
||||||
|
if current_start not in booked_starts:
|
||||||
|
slots.append(BookableSlot(start_time=current_start, end_time=slot_end))
|
||||||
|
|
||||||
|
current_start = slot_end
|
||||||
|
|
||||||
|
return slots
|
||||||
|
|
||||||
|
async def get_available_slots(self, date_param: date) -> AvailableSlotsResponse:
|
||||||
|
"""
|
||||||
|
Get available booking slots for a specific date.
|
||||||
|
|
||||||
|
Returns all slots that:
|
||||||
|
- Fall within admin-defined availability windows
|
||||||
|
- Are not already booked by another user
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date_param: Date to get slots for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AvailableSlotsResponse with date and list of available slots
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BadRequestError: If date is out of range
|
||||||
|
"""
|
||||||
|
validate_date_in_range(date_param, context="book")
|
||||||
|
|
||||||
|
# Get availability for the date
|
||||||
|
availabilities = await self.availability_repo.get_by_date(date_param)
|
||||||
|
|
||||||
|
if not availabilities:
|
||||||
|
return AvailableSlotsResponse(date=date_param, slots=[])
|
||||||
|
|
||||||
|
# Get already booked slots for the date
|
||||||
|
booked_starts = await self.exchange_repo.get_booked_slots_for_date(date_param)
|
||||||
|
|
||||||
|
# Expand each availability into slots
|
||||||
|
all_slots: list[BookableSlot] = []
|
||||||
|
for avail in availabilities:
|
||||||
|
slots = self._expand_availability_to_slots(avail, date_param, booked_starts)
|
||||||
|
all_slots.extend(slots)
|
||||||
|
|
||||||
|
# Sort by start time
|
||||||
|
all_slots.sort(key=lambda s: s.start_time)
|
||||||
|
|
||||||
|
return AvailableSlotsResponse(date=date_param, slots=all_slots)
|
||||||
|
|
|
||||||
193
backend/services/invite.py
Normal file
193
backend/services/invite.py
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
"""Invite service for managing invites."""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from exceptions import BadRequestError, ConflictError, NotFoundError
|
||||||
|
from invite_utils import (
|
||||||
|
generate_invite_identifier,
|
||||||
|
is_valid_identifier_format,
|
||||||
|
normalize_identifier,
|
||||||
|
)
|
||||||
|
from mappers import InviteMapper
|
||||||
|
from models import Invite, InviteStatus
|
||||||
|
from pagination import create_paginated_response
|
||||||
|
from repositories.invite import InviteRepository
|
||||||
|
from schemas import (
|
||||||
|
InviteCheckResponse,
|
||||||
|
PaginatedInviteRecords,
|
||||||
|
)
|
||||||
|
from utils.enum_validation import validate_enum
|
||||||
|
|
||||||
|
MAX_INVITE_COLLISION_RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
class InviteService:
|
||||||
|
"""Service for invite-related business logic."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
self.invite_repo = InviteRepository(db)
|
||||||
|
|
||||||
|
async def check_invite_validity(self, identifier: str) -> InviteCheckResponse:
|
||||||
|
"""
|
||||||
|
Check if an invite is valid and can be used for signup.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
identifier: Invite identifier to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
InviteCheckResponse with validity status
|
||||||
|
"""
|
||||||
|
normalized = normalize_identifier(identifier)
|
||||||
|
|
||||||
|
# Validate format before querying database
|
||||||
|
if not is_valid_identifier_format(normalized):
|
||||||
|
return InviteCheckResponse(valid=False, error="Invalid invite code format")
|
||||||
|
|
||||||
|
invite = await self.invite_repo.get_by_identifier(normalized)
|
||||||
|
|
||||||
|
# Return same error for not found, spent, and revoked
|
||||||
|
# to avoid information leakage
|
||||||
|
if not invite or invite.status in (
|
||||||
|
InviteStatus.SPENT,
|
||||||
|
InviteStatus.REVOKED,
|
||||||
|
):
|
||||||
|
return InviteCheckResponse(valid=False, error="Invite not found")
|
||||||
|
|
||||||
|
return InviteCheckResponse(valid=True, status=invite.status.value)
|
||||||
|
|
||||||
|
async def get_user_invites(self, user_id: int) -> list[Invite]:
|
||||||
|
"""
|
||||||
|
Get all invites owned by a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID of the godfather user
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Invite records, most recent first
|
||||||
|
"""
|
||||||
|
return await self.invite_repo.get_by_godfather_id(user_id, order_by_desc=True)
|
||||||
|
|
||||||
|
async def list_invites(
|
||||||
|
self,
|
||||||
|
page: int,
|
||||||
|
per_page: int,
|
||||||
|
status_filter: str | None = None,
|
||||||
|
godfather_id: int | None = None,
|
||||||
|
) -> PaginatedInviteRecords:
|
||||||
|
"""
|
||||||
|
List invites with pagination and filtering.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: Page number (1-indexed)
|
||||||
|
per_page: Number of records per page
|
||||||
|
status_filter: Optional status filter (ready, spent, revoked)
|
||||||
|
godfather_id: Optional godfather user ID filter
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PaginatedInviteRecords with invites and pagination metadata
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BadRequestError: If status_filter is invalid
|
||||||
|
"""
|
||||||
|
# Validate status filter if provided
|
||||||
|
status_enum = None
|
||||||
|
if status_filter:
|
||||||
|
status_enum = validate_enum(InviteStatus, status_filter, "status")
|
||||||
|
|
||||||
|
# Get total count
|
||||||
|
total = await self.invite_repo.count(
|
||||||
|
status=status_enum, godfather_id=godfather_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get paginated invites
|
||||||
|
invites = await self.invite_repo.list_paginated(
|
||||||
|
page=page,
|
||||||
|
per_page=per_page,
|
||||||
|
status=status_enum,
|
||||||
|
godfather_id=godfather_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build responses using preloaded relationships
|
||||||
|
records = [InviteMapper.to_response(invite) for invite in invites]
|
||||||
|
|
||||||
|
return create_paginated_response(records, total, page, per_page)
|
||||||
|
|
||||||
|
async def create_invite(self, godfather_id: int) -> Invite:
|
||||||
|
"""
|
||||||
|
Create a new invite for a specified godfather user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
godfather_id: ID of the godfather user
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created Invite record
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BadRequestError: If godfather user not found
|
||||||
|
ConflictError: If unable to generate unique invite code after retries
|
||||||
|
"""
|
||||||
|
from repositories.user import UserRepository
|
||||||
|
|
||||||
|
# Validate godfather exists
|
||||||
|
user_repo = UserRepository(self.db)
|
||||||
|
godfather = await user_repo.get_by_id(godfather_id)
|
||||||
|
if not godfather:
|
||||||
|
raise BadRequestError("Godfather user not found")
|
||||||
|
|
||||||
|
# Try to create invite with retry on collision
|
||||||
|
invite: Invite | None = None
|
||||||
|
for attempt in range(MAX_INVITE_COLLISION_RETRIES):
|
||||||
|
identifier = generate_invite_identifier()
|
||||||
|
invite = Invite(
|
||||||
|
identifier=identifier,
|
||||||
|
godfather_id=godfather_id,
|
||||||
|
status=InviteStatus.READY,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
invite = await self.invite_repo.create(invite)
|
||||||
|
# Reload with relationships
|
||||||
|
invite = await self.invite_repo.reload_with_relationships(invite.id)
|
||||||
|
break
|
||||||
|
except IntegrityError:
|
||||||
|
await self.db.rollback()
|
||||||
|
if attempt == MAX_INVITE_COLLISION_RETRIES - 1:
|
||||||
|
raise ConflictError(
|
||||||
|
"Failed to generate unique invite code. Try again."
|
||||||
|
) from None
|
||||||
|
|
||||||
|
if invite is None:
|
||||||
|
raise BadRequestError("Failed to create invite")
|
||||||
|
return invite
|
||||||
|
|
||||||
|
async def revoke_invite(self, invite_id: int) -> Invite:
|
||||||
|
"""
|
||||||
|
Revoke an invite. Only READY invites can be revoked.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
invite_id: ID of the invite to revoke
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Revoked Invite record
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotFoundError: If invite not found
|
||||||
|
BadRequestError: If invite cannot be revoked (not READY)
|
||||||
|
"""
|
||||||
|
invite = await self.invite_repo.get_by_id(invite_id)
|
||||||
|
|
||||||
|
if not invite:
|
||||||
|
raise NotFoundError("Invite")
|
||||||
|
|
||||||
|
if invite.status != InviteStatus.READY:
|
||||||
|
raise BadRequestError(
|
||||||
|
f"Cannot revoke invite with status '{invite.status.value}'. "
|
||||||
|
"Only READY invites can be revoked."
|
||||||
|
)
|
||||||
|
|
||||||
|
invite.status = InviteStatus.REVOKED
|
||||||
|
invite.revoked_at = datetime.now(UTC)
|
||||||
|
return await self.invite_repo.update(invite)
|
||||||
67
backend/services/price.py
Normal file
67
backend/services/price.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
"""Price service for fetching and managing price history."""
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from exceptions import ConflictError
|
||||||
|
from models import PriceHistory
|
||||||
|
from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX, fetch_btc_eur_price
|
||||||
|
from repositories.price import PriceRepository
|
||||||
|
|
||||||
|
PRICE_HISTORY_LIMIT = 20
|
||||||
|
|
||||||
|
|
||||||
|
class PriceService:
|
||||||
|
"""Service for price-related business logic."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
self.price_repo = PriceRepository(db)
|
||||||
|
|
||||||
|
async def get_recent_prices(
|
||||||
|
self, limit: int = PRICE_HISTORY_LIMIT
|
||||||
|
) -> list[PriceHistory]:
|
||||||
|
"""
|
||||||
|
Get recent price history records.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum number of records to return (default: 20)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of PriceHistory records, most recent first
|
||||||
|
"""
|
||||||
|
return await self.price_repo.get_recent(limit)
|
||||||
|
|
||||||
|
async def fetch_and_store_price(self) -> PriceHistory:
|
||||||
|
"""
|
||||||
|
Fetch price from Bitfinex and store it in the database.
|
||||||
|
|
||||||
|
Handles duplicate timestamp conflicts by returning the existing record.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PriceHistory record (newly created or existing if duplicate)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConflictError: If unable to fetch or store price after retries
|
||||||
|
"""
|
||||||
|
price_value, timestamp = await fetch_btc_eur_price()
|
||||||
|
|
||||||
|
record = PriceHistory(
|
||||||
|
source=SOURCE_BITFINEX,
|
||||||
|
pair=PAIR_BTC_EUR,
|
||||||
|
price=price_value,
|
||||||
|
timestamp=timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await self.price_repo.create(record)
|
||||||
|
except IntegrityError:
|
||||||
|
# Duplicate timestamp - return the existing record
|
||||||
|
await self.db.rollback()
|
||||||
|
existing_record = await self.price_repo.get_by_timestamp(
|
||||||
|
timestamp, SOURCE_BITFINEX, PAIR_BTC_EUR
|
||||||
|
)
|
||||||
|
if existing_record:
|
||||||
|
return existing_record
|
||||||
|
# This should not happen, but handle gracefully
|
||||||
|
raise ConflictError("Failed to fetch or store price") from None
|
||||||
81
backend/services/profile.py
Normal file
81
backend/services/profile.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
"""Profile service for managing user profile details."""
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from exceptions import ValidationError
|
||||||
|
from models import User
|
||||||
|
from repositories.user import UserRepository
|
||||||
|
from schemas import ProfileResponse, ProfileUpdate
|
||||||
|
from validation import validate_profile_fields
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileService:
|
||||||
|
"""Service for profile-related business logic."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
self.user_repo = UserRepository(db)
|
||||||
|
|
||||||
|
async def get_profile(self, user: User) -> ProfileResponse:
|
||||||
|
"""
|
||||||
|
Get user profile with godfather email.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The user to get profile for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ProfileResponse with all profile fields and godfather email
|
||||||
|
"""
|
||||||
|
godfather_email = await self.user_repo.get_godfather_email(user.godfather_id)
|
||||||
|
|
||||||
|
return ProfileResponse(
|
||||||
|
contact_email=user.contact_email,
|
||||||
|
telegram=user.telegram,
|
||||||
|
signal=user.signal,
|
||||||
|
nostr_npub=user.nostr_npub,
|
||||||
|
godfather_email=godfather_email,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_profile(self, user: User, data: ProfileUpdate) -> ProfileResponse:
|
||||||
|
"""
|
||||||
|
Validate and update profile fields.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The user to update
|
||||||
|
data: Profile update data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ProfileResponse with updated fields
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: If validation fails (with field_errors dict)
|
||||||
|
"""
|
||||||
|
# Validate all fields
|
||||||
|
errors = validate_profile_fields(
|
||||||
|
contact_email=data.contact_email,
|
||||||
|
telegram=data.telegram,
|
||||||
|
signal=data.signal,
|
||||||
|
nostr_npub=data.nostr_npub,
|
||||||
|
)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
# Keep field_errors format for backward compatibility with frontend
|
||||||
|
raise ValidationError(message="Validation failed", field_errors=errors)
|
||||||
|
|
||||||
|
# Update fields
|
||||||
|
user.contact_email = data.contact_email
|
||||||
|
user.telegram = data.telegram
|
||||||
|
user.signal = data.signal
|
||||||
|
user.nostr_npub = data.nostr_npub
|
||||||
|
|
||||||
|
await self.user_repo.update(user)
|
||||||
|
|
||||||
|
godfather_email = await self.user_repo.get_godfather_email(user.godfather_id)
|
||||||
|
|
||||||
|
return ProfileResponse(
|
||||||
|
contact_email=user.contact_email,
|
||||||
|
telegram=user.telegram,
|
||||||
|
signal=user.signal,
|
||||||
|
nostr_npub=user.nostr_npub,
|
||||||
|
godfather_email=godfather_email,
|
||||||
|
)
|
||||||
|
|
@ -430,7 +430,7 @@ async def test_create_invite_retries_on_collision(
|
||||||
return f"unique-word-{call_count:02d}" # Won't collide
|
return f"unique-word-{call_count:02d}" # Won't collide
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"routes.invites.generate_invite_identifier", side_effect=mock_generator
|
"services.invite.generate_invite_identifier", side_effect=mock_generator
|
||||||
):
|
):
|
||||||
response2 = await client.post(
|
response2 = await client.post(
|
||||||
"/api/admin/invites",
|
"/api/admin/invites",
|
||||||
|
|
|
||||||
|
|
@ -280,7 +280,7 @@ class TestManualFetch:
|
||||||
existing_id = existing.id
|
existing_id = existing.id
|
||||||
|
|
||||||
# Mock fetch_btc_eur_price to return the same timestamp
|
# Mock fetch_btc_eur_price to return the same timestamp
|
||||||
with patch("routes.audit.fetch_btc_eur_price") as mock_fetch:
|
with patch("services.price.fetch_btc_eur_price") as mock_fetch:
|
||||||
mock_fetch.return_value = (95000.0, fixed_timestamp)
|
mock_fetch.return_value = (95000.0, fixed_timestamp)
|
||||||
|
|
||||||
async with client_factory.create(cookies=admin_user["cookies"]) as authed:
|
async with client_factory.create(cookies=admin_user["cookies"]) as authed:
|
||||||
|
|
|
||||||
1
backend/utils/__init__.py
Normal file
1
backend/utils/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Utility modules for common functionality."""
|
||||||
27
backend/utils/date_queries.py
Normal file
27
backend/utils/date_queries.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
"""Utilities for date/time query operations."""
|
||||||
|
|
||||||
|
from datetime import UTC, date, datetime, time
|
||||||
|
|
||||||
|
|
||||||
|
def date_to_start_datetime(d: date) -> datetime:
|
||||||
|
"""Convert a date to datetime at start of day (00:00:00) in UTC."""
|
||||||
|
return datetime.combine(d, time.min, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def date_to_end_datetime(d: date) -> datetime:
|
||||||
|
"""Convert a date to datetime at end of day (23:59:59.999999) in UTC."""
|
||||||
|
return datetime.combine(d, time.max, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def date_range_to_datetime_range(
|
||||||
|
start_date: date, end_date: date
|
||||||
|
) -> tuple[datetime, datetime]:
|
||||||
|
"""
|
||||||
|
Convert a date range to datetime range.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (start_datetime, end_datetime) where:
|
||||||
|
- start_datetime is start_date at 00:00:00 UTC
|
||||||
|
- end_datetime is end_date at 23:59:59.999999 UTC
|
||||||
|
"""
|
||||||
|
return date_to_start_datetime(start_date), date_to_end_datetime(end_date)
|
||||||
32
backend/utils/enum_validation.py
Normal file
32
backend/utils/enum_validation.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""Utilities for validating enum values from strings."""
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
from exceptions import BadRequestError
|
||||||
|
|
||||||
|
T = TypeVar("T", bound=Enum)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_enum(enum_class: type[T], value: str, field_name: str = "value") -> T:
|
||||||
|
"""
|
||||||
|
Validate and convert string to enum.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
enum_class: The enum class to validate against
|
||||||
|
value: The string value to validate
|
||||||
|
field_name: Name of the field for error messages
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated enum value
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BadRequestError: If the value is not a valid enum member
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return enum_class(value)
|
||||||
|
except ValueError:
|
||||||
|
valid_values = ", ".join(e.value for e in enum_class)
|
||||||
|
raise BadRequestError(
|
||||||
|
f"Invalid {field_name}: {value}. Must be one of: {valid_values}"
|
||||||
|
) from None
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
|
|
||||||
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
|
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
|
||||||
|
|
||||||
import { api, ApiError } from "./api";
|
import { api } from "./api";
|
||||||
import { components } from "./generated/api";
|
import { components } from "./generated/api";
|
||||||
|
import { extractApiErrorMessage } from "./utils/error-handling";
|
||||||
|
|
||||||
// Permission type from generated OpenAPI schema
|
// Permission type from generated OpenAPI schema
|
||||||
export type PermissionType = components["schemas"]["Permission"];
|
export type PermissionType = components["schemas"]["Permission"];
|
||||||
|
|
@ -67,11 +68,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const userData = await api.post<User>("/api/auth/login", { email, password });
|
const userData = await api.post<User>("/api/auth/login", { email, password });
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
throw new Error(extractApiErrorMessage(err, "Login failed"));
|
||||||
const data = err.data as { detail?: string };
|
|
||||||
throw new Error(data?.detail || "Login failed");
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -84,11 +81,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
});
|
});
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
throw new Error(extractApiErrorMessage(err, "Registration failed"));
|
||||||
const data = err.data as { detail?: string };
|
|
||||||
throw new Error(data?.detail || "Registration failed");
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
20
frontend/app/components/LoadingState.tsx
Normal file
20
frontend/app/components/LoadingState.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { layoutStyles } from "../styles/shared";
|
||||||
|
|
||||||
|
interface LoadingStateProps {
|
||||||
|
/** Custom loading message (default: "Loading...") */
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard loading state component.
|
||||||
|
* Displays a centered loading message with consistent styling.
|
||||||
|
*/
|
||||||
|
export function LoadingState({ message = "Loading..." }: LoadingStateProps) {
|
||||||
|
return (
|
||||||
|
<main style={layoutStyles.main}>
|
||||||
|
<div style={layoutStyles.loader}>{message}</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
frontend/app/components/Toast.tsx
Normal file
40
frontend/app/components/Toast.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { toastStyles } from "../styles/shared";
|
||||||
|
|
||||||
|
export type ToastType = "success" | "error";
|
||||||
|
|
||||||
|
export interface ToastProps {
|
||||||
|
message: string;
|
||||||
|
type: ToastType;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
/** Auto-dismiss delay in milliseconds (default: 3000) */
|
||||||
|
autoDismissDelay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toast notification component with auto-dismiss functionality.
|
||||||
|
* Displays success or error messages in a fixed position.
|
||||||
|
*/
|
||||||
|
export function Toast({ message, type, onDismiss, autoDismissDelay = 3000 }: ToastProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (onDismiss) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
onDismiss();
|
||||||
|
}, autoDismissDelay);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [onDismiss, autoDismissDelay]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...toastStyles.toast,
|
||||||
|
...(type === "success" ? toastStyles.toastSuccess : toastStyles.toastError),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
347
frontend/app/exchange/components/BookingStep.tsx
Normal file
347
frontend/app/exchange/components/BookingStep.tsx
Normal file
|
|
@ -0,0 +1,347 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CSSProperties } from "react";
|
||||||
|
import { SatsDisplay } from "../../components/SatsDisplay";
|
||||||
|
import { components } from "../../generated/api";
|
||||||
|
import { formatDate, formatTime } from "../../utils/date";
|
||||||
|
import { formatEur } from "../../utils/exchange";
|
||||||
|
import { bannerStyles } from "../../styles/shared";
|
||||||
|
|
||||||
|
type BookableSlot = components["schemas"]["BookableSlot"];
|
||||||
|
type ExchangeResponse = components["schemas"]["ExchangeResponse"];
|
||||||
|
type Direction = "buy" | "sell";
|
||||||
|
type BitcoinTransferMethod = "onchain" | "lightning";
|
||||||
|
|
||||||
|
interface BookingStepProps {
|
||||||
|
direction: Direction;
|
||||||
|
bitcoinTransferMethod: BitcoinTransferMethod;
|
||||||
|
eurAmount: number;
|
||||||
|
satsAmount: number;
|
||||||
|
dates: Date[];
|
||||||
|
selectedDate: Date | null;
|
||||||
|
availableSlots: BookableSlot[];
|
||||||
|
selectedSlot: BookableSlot | null;
|
||||||
|
datesWithAvailability: Set<string>;
|
||||||
|
isLoadingSlots: boolean;
|
||||||
|
isLoadingAvailability: boolean;
|
||||||
|
existingTradeOnSelectedDate: ExchangeResponse | null;
|
||||||
|
userTrades: ExchangeResponse[];
|
||||||
|
onDateSelect: (date: Date) => void;
|
||||||
|
onSlotSelect: (slot: BookableSlot) => void;
|
||||||
|
onBackToDetails: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles: Record<string, CSSProperties> = {
|
||||||
|
summaryCard: {
|
||||||
|
background: "rgba(255, 255, 255, 0.03)",
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.08)",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "1rem 1.5rem",
|
||||||
|
marginBottom: "1.5rem",
|
||||||
|
},
|
||||||
|
summaryHeader: {
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: "0.5rem",
|
||||||
|
},
|
||||||
|
summaryTitle: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
color: "rgba(255, 255, 255, 0.5)",
|
||||||
|
},
|
||||||
|
editButton: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "#a78bfa",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
summaryDetails: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
fontSize: "1rem",
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
summaryDirection: {
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
summaryDivider: {
|
||||||
|
color: "rgba(255, 255, 255, 0.3)",
|
||||||
|
},
|
||||||
|
summaryPaymentMethod: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
color: "rgba(255, 255, 255, 0.6)",
|
||||||
|
},
|
||||||
|
satsValue: {
|
||||||
|
fontFamily: "'DM Mono', monospace",
|
||||||
|
color: "#f7931a", // Bitcoin orange
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
marginBottom: "2rem",
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
fontSize: "1.1rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "#fff",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
},
|
||||||
|
dateGrid: {
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "0.5rem",
|
||||||
|
},
|
||||||
|
dateButton: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
background: "rgba(255, 255, 255, 0.03)",
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.08)",
|
||||||
|
borderRadius: "10px",
|
||||||
|
cursor: "pointer",
|
||||||
|
minWidth: "90px",
|
||||||
|
textAlign: "center" as const,
|
||||||
|
transition: "all 0.2s",
|
||||||
|
},
|
||||||
|
dateButtonSelected: {
|
||||||
|
background: "rgba(167, 139, 250, 0.15)",
|
||||||
|
border: "1px solid #a78bfa",
|
||||||
|
},
|
||||||
|
dateButtonDisabled: {
|
||||||
|
opacity: 0.4,
|
||||||
|
cursor: "not-allowed",
|
||||||
|
background: "rgba(255, 255, 255, 0.01)",
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.04)",
|
||||||
|
},
|
||||||
|
dateButtonHasTrade: {
|
||||||
|
border: "1px solid rgba(251, 146, 60, 0.5)",
|
||||||
|
background: "rgba(251, 146, 60, 0.1)",
|
||||||
|
},
|
||||||
|
dateWeekday: {
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
marginBottom: "0.25rem",
|
||||||
|
},
|
||||||
|
dateDay: {
|
||||||
|
color: "rgba(255, 255, 255, 0.5)",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
},
|
||||||
|
dateWarning: {
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
marginTop: "0.25rem",
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
errorLink: {
|
||||||
|
marginTop: "0.75rem",
|
||||||
|
paddingTop: "0.75rem",
|
||||||
|
borderTop: "1px solid rgba(255, 255, 255, 0.1)",
|
||||||
|
},
|
||||||
|
errorLinkAnchor: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
color: "#a78bfa",
|
||||||
|
textDecoration: "none",
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
},
|
||||||
|
slotGrid: {
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "0.5rem",
|
||||||
|
},
|
||||||
|
slotButton: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
padding: "0.6rem 1.25rem",
|
||||||
|
background: "rgba(255, 255, 255, 0.03)",
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.08)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
transition: "all 0.2s",
|
||||||
|
},
|
||||||
|
slotButtonSelected: {
|
||||||
|
background: "rgba(167, 139, 250, 0.15)",
|
||||||
|
border: "1px solid #a78bfa",
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
color: "rgba(255, 255, 255, 0.4)",
|
||||||
|
padding: "1rem 0",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a date has an existing trade (only consider booked trades, not cancelled ones)
|
||||||
|
*/
|
||||||
|
function getExistingTradeOnDate(
|
||||||
|
date: Date,
|
||||||
|
userTrades: ExchangeResponse[]
|
||||||
|
): ExchangeResponse | null {
|
||||||
|
const dateStr = formatDate(date);
|
||||||
|
return (
|
||||||
|
userTrades.find((trade) => {
|
||||||
|
const tradeDate = formatDate(new Date(trade.slot_start));
|
||||||
|
return tradeDate === dateStr && trade.status === "booked";
|
||||||
|
}) || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 2 of the exchange wizard: Booking
|
||||||
|
* Allows user to select a date and time slot for the exchange.
|
||||||
|
*/
|
||||||
|
export function BookingStep({
|
||||||
|
direction,
|
||||||
|
bitcoinTransferMethod,
|
||||||
|
eurAmount,
|
||||||
|
satsAmount,
|
||||||
|
dates,
|
||||||
|
selectedDate,
|
||||||
|
availableSlots,
|
||||||
|
selectedSlot,
|
||||||
|
datesWithAvailability,
|
||||||
|
isLoadingSlots,
|
||||||
|
isLoadingAvailability,
|
||||||
|
existingTradeOnSelectedDate,
|
||||||
|
userTrades,
|
||||||
|
onDateSelect,
|
||||||
|
onSlotSelect,
|
||||||
|
onBackToDetails,
|
||||||
|
}: BookingStepProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Trade Summary Card */}
|
||||||
|
<div style={styles.summaryCard}>
|
||||||
|
<div style={styles.summaryHeader}>
|
||||||
|
<span style={styles.summaryTitle}>Your Exchange</span>
|
||||||
|
<button onClick={onBackToDetails} style={styles.editButton}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={styles.summaryDetails}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
...styles.summaryDirection,
|
||||||
|
color: direction === "buy" ? "#4ade80" : "#f87171",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{direction === "buy" ? "Buy" : "Sell"} BTC
|
||||||
|
</span>
|
||||||
|
<span style={styles.summaryDivider}>•</span>
|
||||||
|
<span>{formatEur(eurAmount)}</span>
|
||||||
|
<span style={styles.summaryDivider}>↔</span>
|
||||||
|
<span style={styles.satsValue}>
|
||||||
|
<SatsDisplay sats={satsAmount} />
|
||||||
|
</span>
|
||||||
|
<span style={styles.summaryDivider}>•</span>
|
||||||
|
<span style={styles.summaryPaymentMethod}>
|
||||||
|
{direction === "buy" ? "Receive via " : "Send via "}
|
||||||
|
{bitcoinTransferMethod === "onchain" ? "Onchain" : "Lightning"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date Selection */}
|
||||||
|
<div style={styles.section}>
|
||||||
|
<h2 style={styles.sectionTitle}>Select a Date</h2>
|
||||||
|
<div style={styles.dateGrid}>
|
||||||
|
{dates.map((date) => {
|
||||||
|
const dateStr = formatDate(date);
|
||||||
|
const isSelected = selectedDate && formatDate(selectedDate) === dateStr;
|
||||||
|
const hasAvailability = datesWithAvailability.has(dateStr);
|
||||||
|
const isDisabled = !hasAvailability || isLoadingAvailability;
|
||||||
|
const hasExistingTrade = getExistingTradeOnDate(date, userTrades) !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={dateStr}
|
||||||
|
data-testid={`date-${dateStr}`}
|
||||||
|
onClick={() => onDateSelect(date)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
style={{
|
||||||
|
...styles.dateButton,
|
||||||
|
...(isSelected ? styles.dateButtonSelected : {}),
|
||||||
|
...(isDisabled ? styles.dateButtonDisabled : {}),
|
||||||
|
...(hasExistingTrade && !isDisabled ? styles.dateButtonHasTrade : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={styles.dateWeekday}>
|
||||||
|
{date.toLocaleDateString("en-US", { weekday: "short" })}
|
||||||
|
</div>
|
||||||
|
<div style={styles.dateDay}>
|
||||||
|
{date.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{hasExistingTrade && !isDisabled && <div style={styles.dateWarning}>⚠️</div>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning for existing trade on selected date */}
|
||||||
|
{existingTradeOnSelectedDate && (
|
||||||
|
<div style={bannerStyles.errorBanner}>
|
||||||
|
<div>
|
||||||
|
You already have a trade booked on this day. You can only book one trade per day.
|
||||||
|
</div>
|
||||||
|
<div style={styles.errorLink}>
|
||||||
|
<a
|
||||||
|
href={`/trades/${existingTradeOnSelectedDate.public_id}`}
|
||||||
|
style={styles.errorLinkAnchor}
|
||||||
|
>
|
||||||
|
View your existing trade →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Available Slots */}
|
||||||
|
{selectedDate && !existingTradeOnSelectedDate && (
|
||||||
|
<div style={styles.section}>
|
||||||
|
<h2 style={styles.sectionTitle}>
|
||||||
|
Available Slots for{" "}
|
||||||
|
{selectedDate.toLocaleDateString("en-US", {
|
||||||
|
weekday: "long",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{isLoadingSlots ? (
|
||||||
|
<div style={styles.emptyState}>Loading slots...</div>
|
||||||
|
) : availableSlots.length === 0 ? (
|
||||||
|
<div style={styles.emptyState}>No available slots for this date</div>
|
||||||
|
) : (
|
||||||
|
<div style={styles.slotGrid}>
|
||||||
|
{availableSlots.map((slot) => {
|
||||||
|
const isSelected = selectedSlot?.start_time === slot.start_time;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={slot.start_time}
|
||||||
|
onClick={() => onSlotSelect(slot)}
|
||||||
|
style={{
|
||||||
|
...styles.slotButton,
|
||||||
|
...(isSelected ? styles.slotButtonSelected : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatTime(slot.start_time)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
252
frontend/app/exchange/components/ConfirmationStep.tsx
Normal file
252
frontend/app/exchange/components/ConfirmationStep.tsx
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CSSProperties } from "react";
|
||||||
|
import { SatsDisplay } from "../../components/SatsDisplay";
|
||||||
|
import { components } from "../../generated/api";
|
||||||
|
import { formatTime } from "../../utils/date";
|
||||||
|
import { formatEur } from "../../utils/exchange";
|
||||||
|
import { buttonStyles } from "../../styles/shared";
|
||||||
|
|
||||||
|
type BookableSlot = components["schemas"]["BookableSlot"];
|
||||||
|
type Direction = "buy" | "sell";
|
||||||
|
type BitcoinTransferMethod = "onchain" | "lightning";
|
||||||
|
|
||||||
|
interface ConfirmationStepProps {
|
||||||
|
selectedSlot: BookableSlot;
|
||||||
|
selectedDate: Date | null;
|
||||||
|
direction: Direction;
|
||||||
|
bitcoinTransferMethod: BitcoinTransferMethod;
|
||||||
|
eurAmount: number;
|
||||||
|
satsAmount: number;
|
||||||
|
agreedPrice: number;
|
||||||
|
isBooking: boolean;
|
||||||
|
isPriceStale: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format price for display
|
||||||
|
*/
|
||||||
|
function formatPrice(price: number): string {
|
||||||
|
return `€${price.toLocaleString("de-DE", { maximumFractionDigits: 0 })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles: Record<string, CSSProperties> = {
|
||||||
|
confirmCard: {
|
||||||
|
background: "rgba(255, 255, 255, 0.03)",
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.08)",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "1.5rem",
|
||||||
|
maxWidth: "400px",
|
||||||
|
},
|
||||||
|
confirmTitle: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
fontSize: "1.1rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "#fff",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
},
|
||||||
|
confirmDetails: {
|
||||||
|
marginBottom: "1.5rem",
|
||||||
|
},
|
||||||
|
confirmRow: {
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "0.5rem 0",
|
||||||
|
borderBottom: "1px solid rgba(255, 255, 255, 0.05)",
|
||||||
|
},
|
||||||
|
confirmLabel: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
color: "rgba(255, 255, 255, 0.5)",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
},
|
||||||
|
confirmValue: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
satsValue: {
|
||||||
|
fontFamily: "'DM Mono', monospace",
|
||||||
|
color: "#f7931a", // Bitcoin orange
|
||||||
|
},
|
||||||
|
buttonRow: {
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.75rem",
|
||||||
|
},
|
||||||
|
bookButton: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
flex: 1,
|
||||||
|
padding: "0.875rem",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "8px",
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.2s",
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
padding: "0.875rem 1.25rem",
|
||||||
|
background: "rgba(255, 255, 255, 0.05)",
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
color: "rgba(255, 255, 255, 0.7)",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.2s",
|
||||||
|
},
|
||||||
|
compressedBookingCard: {
|
||||||
|
background: "rgba(255, 255, 255, 0.03)",
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.08)",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "1rem 1.5rem",
|
||||||
|
marginBottom: "1.5rem",
|
||||||
|
},
|
||||||
|
compressedBookingHeader: {
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: "0.5rem",
|
||||||
|
},
|
||||||
|
compressedBookingTitle: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
color: "rgba(255, 255, 255, 0.5)",
|
||||||
|
},
|
||||||
|
compressedBookingDetails: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
fontSize: "1rem",
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
summaryDivider: {
|
||||||
|
color: "rgba(255, 255, 255, 0.3)",
|
||||||
|
},
|
||||||
|
editButton: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "#a78bfa",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 3 of the exchange wizard: Confirmation
|
||||||
|
* Shows compressed booking summary and final confirmation form.
|
||||||
|
*/
|
||||||
|
export function ConfirmationStep({
|
||||||
|
selectedSlot,
|
||||||
|
selectedDate,
|
||||||
|
direction,
|
||||||
|
bitcoinTransferMethod,
|
||||||
|
eurAmount,
|
||||||
|
satsAmount,
|
||||||
|
agreedPrice,
|
||||||
|
isBooking,
|
||||||
|
isPriceStale,
|
||||||
|
onConfirm,
|
||||||
|
onBack,
|
||||||
|
}: ConfirmationStepProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Compressed Booking Summary */}
|
||||||
|
<div style={styles.compressedBookingCard}>
|
||||||
|
<div style={styles.compressedBookingHeader}>
|
||||||
|
<span style={styles.compressedBookingTitle}>Appointment</span>
|
||||||
|
<button onClick={onBack} style={styles.editButton}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={styles.compressedBookingDetails}>
|
||||||
|
<span>
|
||||||
|
{selectedDate?.toLocaleDateString("en-US", {
|
||||||
|
weekday: "short",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span style={styles.summaryDivider}>•</span>
|
||||||
|
<span>
|
||||||
|
{formatTime(selectedSlot.start_time)} - {formatTime(selectedSlot.end_time)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirmation Card */}
|
||||||
|
<div style={styles.confirmCard}>
|
||||||
|
<h3 style={styles.confirmTitle}>Confirm Trade</h3>
|
||||||
|
<div style={styles.confirmDetails}>
|
||||||
|
<div style={styles.confirmRow}>
|
||||||
|
<span style={styles.confirmLabel}>Time:</span>
|
||||||
|
<span style={styles.confirmValue}>
|
||||||
|
{formatTime(selectedSlot.start_time)} - {formatTime(selectedSlot.end_time)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={styles.confirmRow}>
|
||||||
|
<span style={styles.confirmLabel}>Direction:</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
...styles.confirmValue,
|
||||||
|
color: direction === "buy" ? "#4ade80" : "#f87171",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{direction === "buy" ? "Buy BTC" : "Sell BTC"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={styles.confirmRow}>
|
||||||
|
<span style={styles.confirmLabel}>EUR:</span>
|
||||||
|
<span style={styles.confirmValue}>{formatEur(eurAmount)}</span>
|
||||||
|
</div>
|
||||||
|
<div style={styles.confirmRow}>
|
||||||
|
<span style={styles.confirmLabel}>BTC:</span>
|
||||||
|
<span style={{ ...styles.confirmValue, ...styles.satsValue }}>
|
||||||
|
<SatsDisplay sats={satsAmount} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={styles.confirmRow}>
|
||||||
|
<span style={styles.confirmLabel}>Rate:</span>
|
||||||
|
<span style={styles.confirmValue}>{formatPrice(agreedPrice)}/BTC</span>
|
||||||
|
</div>
|
||||||
|
<div style={styles.confirmRow}>
|
||||||
|
<span style={styles.confirmLabel}>Payment:</span>
|
||||||
|
<span style={styles.confirmValue}>
|
||||||
|
{direction === "buy" ? "Receive via " : "Send via "}
|
||||||
|
{bitcoinTransferMethod === "onchain" ? "Onchain" : "Lightning"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.buttonRow}>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isBooking || isPriceStale}
|
||||||
|
style={{
|
||||||
|
...styles.bookButton,
|
||||||
|
background:
|
||||||
|
direction === "buy"
|
||||||
|
? "linear-gradient(135deg, #4ade80 0%, #22c55e 100%)"
|
||||||
|
: "linear-gradient(135deg, #f87171 0%, #ef4444 100%)",
|
||||||
|
...(isBooking || isPriceStale ? buttonStyles.buttonDisabled : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isBooking
|
||||||
|
? "Booking..."
|
||||||
|
: isPriceStale
|
||||||
|
? "Price Stale"
|
||||||
|
: `Confirm ${direction === "buy" ? "Buy" : "Sell"}`}
|
||||||
|
</button>
|
||||||
|
<button onClick={onBack} disabled={isBooking} style={styles.cancelButton}>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
377
frontend/app/exchange/components/ExchangeDetailsStep.tsx
Normal file
377
frontend/app/exchange/components/ExchangeDetailsStep.tsx
Normal file
|
|
@ -0,0 +1,377 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChangeEvent, CSSProperties } from "react";
|
||||||
|
import { SatsDisplay } from "../../components/SatsDisplay";
|
||||||
|
import { formatEur } from "../../utils/exchange";
|
||||||
|
import { buttonStyles } from "../../styles/shared";
|
||||||
|
import constants from "../../../../shared/constants.json";
|
||||||
|
|
||||||
|
const { lightningMaxEur: LIGHTNING_MAX_EUR } = constants.exchange;
|
||||||
|
|
||||||
|
type Direction = "buy" | "sell";
|
||||||
|
type BitcoinTransferMethod = "onchain" | "lightning";
|
||||||
|
|
||||||
|
interface ExchangeDetailsStepProps {
|
||||||
|
direction: Direction;
|
||||||
|
onDirectionChange: (direction: Direction) => void;
|
||||||
|
bitcoinTransferMethod: BitcoinTransferMethod;
|
||||||
|
onBitcoinTransferMethodChange: (method: BitcoinTransferMethod) => void;
|
||||||
|
eurAmount: number;
|
||||||
|
onEurAmountChange: (amount: number) => void;
|
||||||
|
satsAmount: number;
|
||||||
|
eurMin: number;
|
||||||
|
eurMax: number;
|
||||||
|
eurIncrement: number;
|
||||||
|
isPriceStale: boolean;
|
||||||
|
hasPrice: boolean;
|
||||||
|
onContinue: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles: Record<string, CSSProperties> = {
|
||||||
|
tradeCard: {
|
||||||
|
background: "rgba(255, 255, 255, 0.03)",
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.08)",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "1.5rem",
|
||||||
|
marginBottom: "2rem",
|
||||||
|
},
|
||||||
|
directionRow: {
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.5rem",
|
||||||
|
marginBottom: "1.5rem",
|
||||||
|
},
|
||||||
|
directionBtn: {
|
||||||
|
flex: 1,
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
fontSize: "1rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: "0.875rem",
|
||||||
|
background: "rgba(255, 255, 255, 0.05)",
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
color: "rgba(255, 255, 255, 0.6)",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.2s",
|
||||||
|
},
|
||||||
|
directionBtnBuyActive: {
|
||||||
|
background: "rgba(74, 222, 128, 0.15)",
|
||||||
|
border: "1px solid #4ade80",
|
||||||
|
color: "#4ade80",
|
||||||
|
},
|
||||||
|
directionBtnSellActive: {
|
||||||
|
background: "rgba(248, 113, 113, 0.15)",
|
||||||
|
border: "1px solid #f87171",
|
||||||
|
color: "#f87171",
|
||||||
|
},
|
||||||
|
paymentMethodSection: {
|
||||||
|
marginBottom: "1.5rem",
|
||||||
|
},
|
||||||
|
paymentMethodLabel: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
color: "rgba(255, 255, 255, 0.7)",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
marginBottom: "0.75rem",
|
||||||
|
},
|
||||||
|
required: {
|
||||||
|
color: "#f87171",
|
||||||
|
},
|
||||||
|
paymentMethodRow: {
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.5rem",
|
||||||
|
},
|
||||||
|
paymentMethodBtn: {
|
||||||
|
flex: 1,
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
fontSize: "0.95rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: "0.875rem",
|
||||||
|
background: "rgba(255, 255, 255, 0.05)",
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
color: "rgba(255, 255, 255, 0.6)",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.2s",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
},
|
||||||
|
paymentMethodBtnActive: {
|
||||||
|
background: "rgba(167, 139, 250, 0.15)",
|
||||||
|
border: "1px solid #a78bfa",
|
||||||
|
color: "#a78bfa",
|
||||||
|
},
|
||||||
|
paymentMethodBtnDisabled: {
|
||||||
|
opacity: 0.4,
|
||||||
|
cursor: "not-allowed",
|
||||||
|
},
|
||||||
|
paymentMethodIcon: {
|
||||||
|
fontSize: "1.2rem",
|
||||||
|
},
|
||||||
|
thresholdMessage: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "rgba(251, 146, 60, 0.9)",
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
padding: "0.5rem",
|
||||||
|
background: "rgba(251, 146, 60, 0.1)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid rgba(251, 146, 60, 0.2)",
|
||||||
|
},
|
||||||
|
amountSection: {
|
||||||
|
marginBottom: "1.5rem",
|
||||||
|
},
|
||||||
|
amountHeader: {
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: "0.75rem",
|
||||||
|
},
|
||||||
|
amountLabel: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
color: "rgba(255, 255, 255, 0.7)",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
},
|
||||||
|
amountInputWrapper: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
background: "rgba(255, 255, 255, 0.05)",
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
},
|
||||||
|
amountCurrency: {
|
||||||
|
fontFamily: "'DM Mono', monospace",
|
||||||
|
color: "rgba(255, 255, 255, 0.5)",
|
||||||
|
fontSize: "1rem",
|
||||||
|
marginRight: "0.25rem",
|
||||||
|
},
|
||||||
|
amountInput: {
|
||||||
|
fontFamily: "'DM Mono', monospace",
|
||||||
|
fontSize: "1.25rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#fff",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
width: "80px",
|
||||||
|
textAlign: "right" as const,
|
||||||
|
},
|
||||||
|
slider: {
|
||||||
|
width: "100%",
|
||||||
|
height: "8px",
|
||||||
|
appearance: "none" as const,
|
||||||
|
background: "rgba(255, 255, 255, 0.1)",
|
||||||
|
borderRadius: "4px",
|
||||||
|
outline: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
},
|
||||||
|
amountRange: {
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "rgba(255, 255, 255, 0.4)",
|
||||||
|
},
|
||||||
|
tradeSummary: {
|
||||||
|
background: "rgba(255, 255, 255, 0.02)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "1rem",
|
||||||
|
textAlign: "center" as const,
|
||||||
|
marginBottom: "1.5rem",
|
||||||
|
},
|
||||||
|
summaryText: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
color: "rgba(255, 255, 255, 0.8)",
|
||||||
|
fontSize: "0.95rem",
|
||||||
|
margin: 0,
|
||||||
|
},
|
||||||
|
satsValue: {
|
||||||
|
fontFamily: "'DM Mono', monospace",
|
||||||
|
color: "#f7931a", // Bitcoin orange
|
||||||
|
},
|
||||||
|
continueButton: {
|
||||||
|
width: "100%",
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
fontSize: "1rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: "0.875rem",
|
||||||
|
background: "linear-gradient(135deg, #a78bfa 0%, #8b5cf6 100%)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "8px",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.2s",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 1 of the exchange wizard: Exchange Details
|
||||||
|
* Allows user to select direction (buy/sell), payment method, and amount.
|
||||||
|
*/
|
||||||
|
export function ExchangeDetailsStep({
|
||||||
|
direction,
|
||||||
|
onDirectionChange,
|
||||||
|
bitcoinTransferMethod,
|
||||||
|
onBitcoinTransferMethodChange,
|
||||||
|
eurAmount,
|
||||||
|
onEurAmountChange,
|
||||||
|
satsAmount,
|
||||||
|
eurMin,
|
||||||
|
eurMax,
|
||||||
|
eurIncrement,
|
||||||
|
isPriceStale,
|
||||||
|
hasPrice,
|
||||||
|
onContinue,
|
||||||
|
}: ExchangeDetailsStepProps) {
|
||||||
|
const isLightningDisabled = eurAmount > LIGHTNING_MAX_EUR * 100;
|
||||||
|
|
||||||
|
const handleAmountChange = (value: number) => {
|
||||||
|
// Clamp to valid range and snap to increment
|
||||||
|
const minCents = eurMin * 100;
|
||||||
|
const maxCents = eurMax * 100;
|
||||||
|
const incrementCents = eurIncrement * 100;
|
||||||
|
|
||||||
|
// Clamp value
|
||||||
|
let clamped = Math.max(minCents, Math.min(maxCents, value));
|
||||||
|
|
||||||
|
// Snap to nearest increment
|
||||||
|
clamped = Math.round(clamped / incrementCents) * incrementCents;
|
||||||
|
|
||||||
|
onEurAmountChange(clamped);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAmountInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const inputValue = e.target.value.replace(/[^0-9]/g, "");
|
||||||
|
if (inputValue === "") {
|
||||||
|
onEurAmountChange(eurMin * 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const eurValue = parseInt(inputValue, 10);
|
||||||
|
handleAmountChange(eurValue * 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.tradeCard}>
|
||||||
|
{/* Direction Selector */}
|
||||||
|
<div style={styles.directionRow}>
|
||||||
|
<button
|
||||||
|
onClick={() => onDirectionChange("buy")}
|
||||||
|
style={{
|
||||||
|
...styles.directionBtn,
|
||||||
|
...(direction === "buy" ? styles.directionBtnBuyActive : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Buy BTC
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onDirectionChange("sell")}
|
||||||
|
style={{
|
||||||
|
...styles.directionBtn,
|
||||||
|
...(direction === "sell" ? styles.directionBtnSellActive : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sell BTC
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment Method Selector */}
|
||||||
|
<div style={styles.paymentMethodSection}>
|
||||||
|
<div style={styles.paymentMethodLabel}>
|
||||||
|
Payment Method <span style={styles.required}>*</span>
|
||||||
|
</div>
|
||||||
|
<div style={styles.paymentMethodRow}>
|
||||||
|
<button
|
||||||
|
onClick={() => onBitcoinTransferMethodChange("onchain")}
|
||||||
|
style={{
|
||||||
|
...styles.paymentMethodBtn,
|
||||||
|
...(bitcoinTransferMethod === "onchain" ? styles.paymentMethodBtnActive : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={styles.paymentMethodIcon}>🔗</span>
|
||||||
|
<span>Onchain</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onBitcoinTransferMethodChange("lightning")}
|
||||||
|
disabled={isLightningDisabled}
|
||||||
|
style={{
|
||||||
|
...styles.paymentMethodBtn,
|
||||||
|
...(bitcoinTransferMethod === "lightning" ? styles.paymentMethodBtnActive : {}),
|
||||||
|
...(isLightningDisabled ? styles.paymentMethodBtnDisabled : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={styles.paymentMethodIcon}>⚡</span>
|
||||||
|
<span>Lightning</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isLightningDisabled && (
|
||||||
|
<div style={styles.thresholdMessage}>
|
||||||
|
Lightning payments are only available for amounts up to €{LIGHTNING_MAX_EUR}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Amount Section */}
|
||||||
|
<div style={styles.amountSection}>
|
||||||
|
<div style={styles.amountHeader}>
|
||||||
|
<span style={styles.amountLabel}>Amount (EUR)</span>
|
||||||
|
<div style={styles.amountInputWrapper}>
|
||||||
|
<span style={styles.amountCurrency}>€</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={Math.round(eurAmount / 100)}
|
||||||
|
onChange={handleAmountInputChange}
|
||||||
|
style={styles.amountInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={eurMin * 100}
|
||||||
|
max={eurMax * 100}
|
||||||
|
step={eurIncrement * 100}
|
||||||
|
value={eurAmount}
|
||||||
|
onChange={(e) => onEurAmountChange(Number(e.target.value))}
|
||||||
|
style={styles.slider}
|
||||||
|
/>
|
||||||
|
<div style={styles.amountRange}>
|
||||||
|
<span>{formatEur(eurMin * 100)}</span>
|
||||||
|
<span>{formatEur(eurMax * 100)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trade Summary */}
|
||||||
|
<div style={styles.tradeSummary}>
|
||||||
|
{direction === "buy" ? (
|
||||||
|
<p style={styles.summaryText}>
|
||||||
|
You buy{" "}
|
||||||
|
<strong style={styles.satsValue}>
|
||||||
|
<SatsDisplay sats={satsAmount} />
|
||||||
|
</strong>
|
||||||
|
, you sell <strong>{formatEur(eurAmount)}</strong>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p style={styles.summaryText}>
|
||||||
|
You buy <strong>{formatEur(eurAmount)}</strong>, you sell{" "}
|
||||||
|
<strong style={styles.satsValue}>
|
||||||
|
<SatsDisplay sats={satsAmount} />
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Continue Button */}
|
||||||
|
<button
|
||||||
|
onClick={onContinue}
|
||||||
|
disabled={isPriceStale || !hasPrice}
|
||||||
|
style={{
|
||||||
|
...styles.continueButton,
|
||||||
|
...(isPriceStale || !hasPrice ? buttonStyles.buttonDisabled : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Continue to Booking
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
frontend/app/exchange/components/PriceDisplay.tsx
Normal file
130
frontend/app/exchange/components/PriceDisplay.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CSSProperties } from "react";
|
||||||
|
import { components } from "../../generated/api";
|
||||||
|
|
||||||
|
type ExchangePriceResponse = components["schemas"]["ExchangePriceResponse"];
|
||||||
|
|
||||||
|
interface PriceDisplayProps {
|
||||||
|
priceData: ExchangePriceResponse | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
lastUpdate: Date | null;
|
||||||
|
direction: "buy" | "sell";
|
||||||
|
agreedPrice: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format price for display
|
||||||
|
*/
|
||||||
|
function formatPrice(price: number): string {
|
||||||
|
return `€${price.toLocaleString("de-DE", { maximumFractionDigits: 0 })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles: Record<string, CSSProperties> = {
|
||||||
|
priceCard: {
|
||||||
|
background: "rgba(255, 255, 255, 0.03)",
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.08)",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "1rem 1.5rem",
|
||||||
|
marginBottom: "1.5rem",
|
||||||
|
},
|
||||||
|
priceRow: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.75rem",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
},
|
||||||
|
priceLabel: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
color: "rgba(255, 255, 255, 0.5)",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
},
|
||||||
|
priceValue: {
|
||||||
|
fontFamily: "'DM Mono', monospace",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: "1.1rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
priceDivider: {
|
||||||
|
color: "rgba(255, 255, 255, 0.2)",
|
||||||
|
margin: "0 0.25rem",
|
||||||
|
},
|
||||||
|
premiumBadge: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
padding: "0.2rem 0.5rem",
|
||||||
|
borderRadius: "4px",
|
||||||
|
marginLeft: "0.25rem",
|
||||||
|
background: "rgba(255, 255, 255, 0.1)",
|
||||||
|
color: "rgba(255, 255, 255, 0.7)",
|
||||||
|
},
|
||||||
|
priceTimestamp: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "rgba(255, 255, 255, 0.4)",
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
},
|
||||||
|
staleWarning: {
|
||||||
|
color: "#f87171",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
priceLoading: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
color: "rgba(255, 255, 255, 0.5)",
|
||||||
|
textAlign: "center" as const,
|
||||||
|
},
|
||||||
|
priceError: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
color: "#f87171",
|
||||||
|
textAlign: "center" as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that displays exchange price information.
|
||||||
|
* Shows market price, agreed price, premium percentage, and last update time.
|
||||||
|
*/
|
||||||
|
export function PriceDisplay({
|
||||||
|
priceData,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
lastUpdate,
|
||||||
|
direction,
|
||||||
|
agreedPrice,
|
||||||
|
}: PriceDisplayProps) {
|
||||||
|
const marketPrice = priceData?.price?.market_price ?? 0;
|
||||||
|
const premiumPercent = priceData?.price?.premium_percentage ?? 5;
|
||||||
|
const isPriceStale = priceData?.price?.is_stale ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.priceCard}>
|
||||||
|
{isLoading && !priceData ? (
|
||||||
|
<div style={styles.priceLoading}>Loading price...</div>
|
||||||
|
) : error && !priceData?.price ? (
|
||||||
|
<div style={styles.priceError}>{error}</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={styles.priceRow}>
|
||||||
|
<span style={styles.priceLabel}>Market:</span>
|
||||||
|
<span style={styles.priceValue}>{formatPrice(marketPrice)}</span>
|
||||||
|
<span style={styles.priceDivider}>•</span>
|
||||||
|
<span style={styles.priceLabel}>Our price:</span>
|
||||||
|
<span style={styles.priceValue}>{formatPrice(agreedPrice)}</span>
|
||||||
|
<span style={styles.premiumBadge}>
|
||||||
|
{direction === "buy" ? "+" : "-"}
|
||||||
|
{premiumPercent}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{lastUpdate && (
|
||||||
|
<div style={styles.priceTimestamp}>
|
||||||
|
Updated {lastUpdate.toLocaleTimeString()}
|
||||||
|
{isPriceStale && <span style={styles.staleWarning}> (stale)</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
frontend/app/exchange/components/StepIndicator.tsx
Normal file
98
frontend/app/exchange/components/StepIndicator.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CSSProperties } from "react";
|
||||||
|
|
||||||
|
type WizardStep = "details" | "booking" | "confirmation";
|
||||||
|
|
||||||
|
interface StepIndicatorProps {
|
||||||
|
currentStep: WizardStep;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles: Record<string, CSSProperties> = {
|
||||||
|
stepIndicator: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "1rem",
|
||||||
|
marginBottom: "2rem",
|
||||||
|
},
|
||||||
|
step: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
opacity: 0.4,
|
||||||
|
},
|
||||||
|
stepActive: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
stepCompleted: {
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
stepNumber: {
|
||||||
|
fontFamily: "'DM Mono', monospace",
|
||||||
|
width: "28px",
|
||||||
|
height: "28px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "rgba(255, 255, 255, 0.1)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
stepLabel: {
|
||||||
|
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
stepDivider: {
|
||||||
|
width: "40px",
|
||||||
|
height: "1px",
|
||||||
|
background: "rgba(255, 255, 255, 0.2)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that displays the wizard step indicator.
|
||||||
|
* Shows which step the user is currently on and which steps are completed.
|
||||||
|
*/
|
||||||
|
export function StepIndicator({ currentStep }: StepIndicatorProps) {
|
||||||
|
return (
|
||||||
|
<div style={styles.stepIndicator}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...styles.step,
|
||||||
|
...(currentStep === "details" ? styles.stepActive : styles.stepCompleted),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={styles.stepNumber}>1</span>
|
||||||
|
<span style={styles.stepLabel}>Exchange Details</span>
|
||||||
|
</div>
|
||||||
|
<div style={styles.stepDivider} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...styles.step,
|
||||||
|
...(currentStep === "booking"
|
||||||
|
? styles.stepActive
|
||||||
|
: currentStep === "confirmation"
|
||||||
|
? styles.stepCompleted
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={styles.stepNumber}>2</span>
|
||||||
|
<span style={styles.stepLabel}>Book Appointment</span>
|
||||||
|
</div>
|
||||||
|
<div style={styles.stepDivider} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...styles.step,
|
||||||
|
...(currentStep === "confirmation" ? styles.stepActive : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={styles.stepNumber}>3</span>
|
||||||
|
<span style={styles.stepLabel}>Confirm</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
frontend/app/exchange/hooks/useAvailableSlots.ts
Normal file
97
frontend/app/exchange/hooks/useAvailableSlots.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { api } from "../../api";
|
||||||
|
import { components } from "../../generated/api";
|
||||||
|
import { formatDate } from "../../utils/date";
|
||||||
|
|
||||||
|
type BookableSlot = components["schemas"]["BookableSlot"];
|
||||||
|
type AvailableSlotsResponse = components["schemas"]["AvailableSlotsResponse"];
|
||||||
|
|
||||||
|
interface UseAvailableSlotsOptions {
|
||||||
|
/** Whether the user is authenticated and authorized */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Dates to check availability for */
|
||||||
|
dates: Date[];
|
||||||
|
/** Current wizard step - only fetch when in booking or confirmation step */
|
||||||
|
wizardStep?: "details" | "booking" | "confirmation";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseAvailableSlotsResult {
|
||||||
|
/** Available slots for the selected date */
|
||||||
|
availableSlots: BookableSlot[];
|
||||||
|
/** Set of date strings that have availability */
|
||||||
|
datesWithAvailability: Set<string>;
|
||||||
|
/** Whether slots are currently being loaded for a specific date */
|
||||||
|
isLoadingSlots: boolean;
|
||||||
|
/** Whether availability is being checked for all dates */
|
||||||
|
isLoadingAvailability: boolean;
|
||||||
|
/** Fetch slots for a specific date */
|
||||||
|
fetchSlots: (date: Date) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing available slots and date availability.
|
||||||
|
* Fetches availability for all dates when entering booking/confirmation steps.
|
||||||
|
*/
|
||||||
|
export function useAvailableSlots(options: UseAvailableSlotsOptions): UseAvailableSlotsResult {
|
||||||
|
const { enabled = true, dates, wizardStep } = options;
|
||||||
|
const [availableSlots, setAvailableSlots] = useState<BookableSlot[]>([]);
|
||||||
|
const [datesWithAvailability, setDatesWithAvailability] = useState<Set<string>>(new Set());
|
||||||
|
const [isLoadingSlots, setIsLoadingSlots] = useState(false);
|
||||||
|
const [isLoadingAvailability, setIsLoadingAvailability] = useState(true);
|
||||||
|
|
||||||
|
const fetchSlots = useCallback(
|
||||||
|
async (date: Date) => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
setIsLoadingSlots(true);
|
||||||
|
setAvailableSlots([]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dateStr = formatDate(date);
|
||||||
|
const data = await api.get<AvailableSlotsResponse>(`/api/exchange/slots?date=${dateStr}`);
|
||||||
|
setAvailableSlots(data.slots);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch slots:", err);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingSlots(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[enabled]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch availability for all dates when entering booking or confirmation step
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || (wizardStep !== "booking" && wizardStep !== "confirmation")) return;
|
||||||
|
|
||||||
|
const fetchAllAvailability = async () => {
|
||||||
|
setIsLoadingAvailability(true);
|
||||||
|
const availabilitySet = new Set<string>();
|
||||||
|
|
||||||
|
const promises = dates.map(async (date) => {
|
||||||
|
try {
|
||||||
|
const dateStr = formatDate(date);
|
||||||
|
const data = await api.get<AvailableSlotsResponse>(`/api/exchange/slots?date=${dateStr}`);
|
||||||
|
if (data.slots.length > 0) {
|
||||||
|
availabilitySet.add(dateStr);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to fetch availability for ${formatDate(date)}:`, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
setDatesWithAvailability(availabilitySet);
|
||||||
|
setIsLoadingAvailability(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchAllAvailability();
|
||||||
|
}, [enabled, dates, wizardStep]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
availableSlots,
|
||||||
|
datesWithAvailability,
|
||||||
|
isLoadingSlots,
|
||||||
|
isLoadingAvailability,
|
||||||
|
fetchSlots,
|
||||||
|
};
|
||||||
|
}
|
||||||
73
frontend/app/exchange/hooks/useExchangePrice.ts
Normal file
73
frontend/app/exchange/hooks/useExchangePrice.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { api } from "../../api";
|
||||||
|
import { components } from "../../generated/api";
|
||||||
|
|
||||||
|
type ExchangePriceResponse = components["schemas"]["ExchangePriceResponse"];
|
||||||
|
|
||||||
|
interface UseExchangePriceOptions {
|
||||||
|
/** Whether the user is authenticated and authorized */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Auto-refresh interval in milliseconds (default: 60000) */
|
||||||
|
refreshInterval?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseExchangePriceResult {
|
||||||
|
priceData: ExchangePriceResponse | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
lastUpdate: Date | null;
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for fetching and managing exchange price data.
|
||||||
|
* Automatically refreshes price data at specified intervals.
|
||||||
|
*/
|
||||||
|
export function useExchangePrice(options: UseExchangePriceOptions = {}): UseExchangePriceResult {
|
||||||
|
const { enabled = true, refreshInterval = 60000 } = options;
|
||||||
|
const [priceData, setPriceData] = useState<ExchangePriceResponse | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||||
|
|
||||||
|
const fetchPrice = useCallback(async () => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.get<ExchangePriceResponse>("/api/exchange/price");
|
||||||
|
setPriceData(data);
|
||||||
|
setLastUpdate(new Date());
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
setError(data.error);
|
||||||
|
}
|
||||||
|
if (data.price?.is_stale) {
|
||||||
|
setError("Price is stale. Trade booking may be blocked.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch price:", err);
|
||||||
|
setError("Failed to load price data");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
fetchPrice();
|
||||||
|
const interval = setInterval(fetchPrice, refreshInterval);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [enabled, fetchPrice, refreshInterval]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
priceData,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
lastUpdate,
|
||||||
|
refetch: fetchPrice,
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
54
frontend/app/hooks/useDebouncedValidation.ts
Normal file
54
frontend/app/hooks/useDebouncedValidation.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for debounced form validation.
|
||||||
|
* Validates form data after the user stops typing for a specified delay.
|
||||||
|
*
|
||||||
|
* @param formData - The form data to validate
|
||||||
|
* @param validator - Function that validates the form data and returns field errors
|
||||||
|
* @param delay - Debounce delay in milliseconds (default: 500)
|
||||||
|
* @returns Object containing current errors and a function to manually trigger validation
|
||||||
|
*/
|
||||||
|
export function useDebouncedValidation<T>(
|
||||||
|
formData: T,
|
||||||
|
validator: (data: T) => Record<string, string>,
|
||||||
|
delay: number = 500
|
||||||
|
): {
|
||||||
|
errors: Record<string, string>;
|
||||||
|
setErrors: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
||||||
|
validate: (data?: T) => void;
|
||||||
|
} {
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const validationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const formDataRef = useRef<T>(formData);
|
||||||
|
|
||||||
|
// Keep formDataRef in sync with formData
|
||||||
|
useEffect(() => {
|
||||||
|
formDataRef.current = formData;
|
||||||
|
}, [formData]);
|
||||||
|
|
||||||
|
// Cleanup timeout on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (validationTimeoutRef.current) {
|
||||||
|
clearTimeout(validationTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const validate = (data?: T) => {
|
||||||
|
// Clear any pending validation timeout
|
||||||
|
if (validationTimeoutRef.current) {
|
||||||
|
clearTimeout(validationTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce validation - wait for user to stop typing
|
||||||
|
validationTimeoutRef.current = setTimeout(() => {
|
||||||
|
const dataToValidate = data ?? formDataRef.current;
|
||||||
|
const newErrors = validator(dataToValidate);
|
||||||
|
setErrors(newErrors);
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { errors, setErrors, validate };
|
||||||
|
}
|
||||||
|
|
@ -46,6 +46,7 @@ export function useRequireAuth(options: UseRequireAuthOptions = {}): UseRequireA
|
||||||
|
|
||||||
if (!isAuthorized) {
|
if (!isAuthorized) {
|
||||||
// Redirect to the most appropriate page based on permissions
|
// Redirect to the most appropriate page based on permissions
|
||||||
|
// Use hasPermission/hasRole directly since they're stable callbacks
|
||||||
const redirect =
|
const redirect =
|
||||||
fallbackRedirect ??
|
fallbackRedirect ??
|
||||||
(hasPermission(Permission.VIEW_ALL_EXCHANGES)
|
(hasPermission(Permission.VIEW_ALL_EXCHANGES)
|
||||||
|
|
@ -55,7 +56,11 @@ export function useRequireAuth(options: UseRequireAuthOptions = {}): UseRequireA
|
||||||
: "/login");
|
: "/login");
|
||||||
router.push(redirect);
|
router.push(redirect);
|
||||||
}
|
}
|
||||||
}, [isLoading, user, isAuthorized, router, fallbackRedirect, hasPermission]);
|
// Note: hasPermission and hasRole are stable callbacks from useAuth,
|
||||||
|
// so they don't need to be in the dependency array. They're only included
|
||||||
|
// for clarity and to satisfy exhaustive-deps if needed.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isLoading, user, isAuthorized, router, fallbackRedirect]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,24 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
|
||||||
import { api, ApiError } from "../api";
|
import { api } from "../api";
|
||||||
|
import { extractApiErrorMessage, extractFieldErrors } from "../utils/error-handling";
|
||||||
import { Permission } from "../auth-context";
|
import { Permission } from "../auth-context";
|
||||||
import { Header } from "../components/Header";
|
import { Header } from "../components/Header";
|
||||||
|
import { Toast } from "../components/Toast";
|
||||||
|
import { LoadingState } from "../components/LoadingState";
|
||||||
import { components } from "../generated/api";
|
import { components } from "../generated/api";
|
||||||
import { useRequireAuth } from "../hooks/useRequireAuth";
|
import { useRequireAuth } from "../hooks/useRequireAuth";
|
||||||
|
import { useDebouncedValidation } from "../hooks/useDebouncedValidation";
|
||||||
import {
|
import {
|
||||||
layoutStyles,
|
layoutStyles,
|
||||||
cardStyles,
|
cardStyles,
|
||||||
formStyles,
|
formStyles,
|
||||||
buttonStyles,
|
buttonStyles,
|
||||||
toastStyles,
|
|
||||||
utilityStyles,
|
utilityStyles,
|
||||||
} from "../styles/shared";
|
} from "../styles/shared";
|
||||||
import { FieldErrors, validateProfileFields } from "../utils/validation";
|
import { validateProfileFields } from "../utils/validation";
|
||||||
|
|
||||||
// Use generated type from OpenAPI schema
|
// Use generated type from OpenAPI schema
|
||||||
type ProfileData = components["schemas"]["ProfileResponse"];
|
type ProfileData = components["schemas"]["ProfileResponse"];
|
||||||
|
|
@ -50,11 +53,15 @@ export default function ProfilePage() {
|
||||||
nostr_npub: "",
|
nostr_npub: "",
|
||||||
});
|
});
|
||||||
const [godfatherEmail, setGodfatherEmail] = useState<string | null>(null);
|
const [godfatherEmail, setGodfatherEmail] = useState<string | null>(null);
|
||||||
const [errors, setErrors] = useState<FieldErrors>({});
|
|
||||||
const [isLoadingProfile, setIsLoadingProfile] = useState(true);
|
const [isLoadingProfile, setIsLoadingProfile] = useState(true);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
||||||
const validationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
|
const {
|
||||||
|
errors,
|
||||||
|
setErrors,
|
||||||
|
validate: validateForm,
|
||||||
|
} = useDebouncedValidation(formData, validateProfileFields, 500);
|
||||||
|
|
||||||
// Check if form has changes
|
// Check if form has changes
|
||||||
const hasChanges = useCallback(() => {
|
const hasChanges = useCallback(() => {
|
||||||
|
|
@ -93,23 +100,6 @@ export default function ProfilePage() {
|
||||||
}
|
}
|
||||||
}, [user, isAuthorized, fetchProfile]);
|
}, [user, isAuthorized, fetchProfile]);
|
||||||
|
|
||||||
// Auto-dismiss toast after 3 seconds
|
|
||||||
useEffect(() => {
|
|
||||||
if (toast) {
|
|
||||||
const timer = setTimeout(() => setToast(null), 3000);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [toast]);
|
|
||||||
|
|
||||||
// Cleanup validation timeout on unmount
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (validationTimeoutRef.current) {
|
|
||||||
clearTimeout(validationTimeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleInputChange = (field: keyof FormData) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (field: keyof FormData) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
let value = e.target.value;
|
let value = e.target.value;
|
||||||
|
|
||||||
|
|
@ -121,19 +111,11 @@ export default function ProfilePage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
const newFormData = { ...formData, [field]: value };
|
||||||
|
setFormData(newFormData);
|
||||||
|
|
||||||
// Clear any pending validation timeout
|
// Trigger debounced validation with the new data
|
||||||
if (validationTimeoutRef.current) {
|
validateForm(newFormData);
|
||||||
clearTimeout(validationTimeoutRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debounce validation - wait 500ms after user stops typing
|
|
||||||
validationTimeoutRef.current = setTimeout(() => {
|
|
||||||
const newFormData = { ...formData, [field]: value };
|
|
||||||
const newErrors = validateProfileFields(newFormData);
|
|
||||||
setErrors(newErrors);
|
|
||||||
}, 500);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
|
@ -162,14 +144,15 @@ export default function ProfilePage() {
|
||||||
setToast({ message: "Profile saved successfully!", type: "success" });
|
setToast({ message: "Profile saved successfully!", type: "success" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Profile save error:", err);
|
console.error("Profile save error:", err);
|
||||||
if (err instanceof ApiError && err.status === 422) {
|
const fieldErrors = extractFieldErrors(err);
|
||||||
const errorData = err.data as { detail?: { field_errors?: FieldErrors } };
|
if (fieldErrors?.detail?.field_errors) {
|
||||||
if (errorData?.detail?.field_errors) {
|
setErrors(fieldErrors.detail.field_errors);
|
||||||
setErrors(errorData.detail.field_errors);
|
|
||||||
}
|
|
||||||
setToast({ message: "Please fix the errors below", type: "error" });
|
setToast({ message: "Please fix the errors below", type: "error" });
|
||||||
} else {
|
} else {
|
||||||
setToast({ message: "Network error. Please try again.", type: "error" });
|
setToast({
|
||||||
|
message: extractApiErrorMessage(err, "Network error. Please try again."),
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
|
|
@ -177,11 +160,7 @@ export default function ProfilePage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading || isLoadingProfile) {
|
if (isLoading || isLoadingProfile) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<main style={layoutStyles.main}>
|
|
||||||
<div style={layoutStyles.loader}>Loading...</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user || !isAuthorized) {
|
if (!user || !isAuthorized) {
|
||||||
|
|
@ -194,14 +173,7 @@ export default function ProfilePage() {
|
||||||
<main style={layoutStyles.main}>
|
<main style={layoutStyles.main}>
|
||||||
{/* Toast notification */}
|
{/* Toast notification */}
|
||||||
{toast && (
|
{toast && (
|
||||||
<div
|
<Toast message={toast.message} type={toast.type} onDismiss={() => setToast(null)} />
|
||||||
style={{
|
|
||||||
...toastStyles.toast,
|
|
||||||
...(toast.type === "success" ? toastStyles.toastSuccess : toastStyles.toastError),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{toast.message}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Header currentPage="profile" />
|
<Header currentPage="profile" />
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,21 @@
|
||||||
import { CSSProperties } from "react";
|
import { CSSProperties } from "react";
|
||||||
|
|
||||||
|
// Import shared tokens and styles to avoid duplication
|
||||||
|
// Note: We can't directly import tokens from shared.ts as it's not exported,
|
||||||
|
// so we'll use the shared style objects where possible
|
||||||
|
import {
|
||||||
|
layoutStyles,
|
||||||
|
cardStyles,
|
||||||
|
formStyles,
|
||||||
|
buttonStyles,
|
||||||
|
bannerStyles,
|
||||||
|
typographyStyles,
|
||||||
|
} from "./shared";
|
||||||
|
|
||||||
export const authFormStyles: Record<string, CSSProperties> = {
|
export const authFormStyles: Record<string, CSSProperties> = {
|
||||||
main: {
|
main: {
|
||||||
|
...layoutStyles.contentCentered,
|
||||||
minHeight: "100vh",
|
minHeight: "100vh",
|
||||||
background: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #2d1b4e 100%)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
padding: "1rem",
|
padding: "1rem",
|
||||||
},
|
},
|
||||||
container: {
|
container: {
|
||||||
|
|
@ -14,80 +23,41 @@ export const authFormStyles: Record<string, CSSProperties> = {
|
||||||
maxWidth: "420px",
|
maxWidth: "420px",
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
background: "rgba(255, 255, 255, 0.03)",
|
...cardStyles.card,
|
||||||
backdropFilter: "blur(10px)",
|
|
||||||
border: "1px solid rgba(255, 255, 255, 0.08)",
|
|
||||||
borderRadius: "24px",
|
|
||||||
padding: "3rem 2.5rem",
|
padding: "3rem 2.5rem",
|
||||||
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)",
|
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
textAlign: "center" as const,
|
textAlign: "center" as const,
|
||||||
marginBottom: "2.5rem",
|
marginBottom: "2.5rem",
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontFamily: "'Instrument Serif', Georgia, serif",
|
...typographyStyles.pageTitle,
|
||||||
fontSize: "2.5rem",
|
fontSize: "2.5rem",
|
||||||
fontWeight: 400,
|
textAlign: "center" as const,
|
||||||
color: "#fff",
|
|
||||||
margin: 0,
|
|
||||||
letterSpacing: "-0.02em",
|
|
||||||
},
|
},
|
||||||
subtitle: {
|
subtitle: {
|
||||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
...typographyStyles.pageSubtitle,
|
||||||
color: "rgba(255, 255, 255, 0.5)",
|
textAlign: "center" as const,
|
||||||
marginTop: "0.5rem",
|
|
||||||
fontSize: "0.95rem",
|
|
||||||
},
|
},
|
||||||
form: {
|
form: {
|
||||||
display: "flex",
|
...formStyles.form,
|
||||||
flexDirection: "column" as const,
|
|
||||||
gap: "1.5rem",
|
gap: "1.5rem",
|
||||||
},
|
},
|
||||||
field: {
|
field: {
|
||||||
display: "flex",
|
...formStyles.field,
|
||||||
flexDirection: "column" as const,
|
|
||||||
gap: "0.5rem",
|
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
...formStyles.label,
|
||||||
color: "rgba(255, 255, 255, 0.7)",
|
|
||||||
fontSize: "0.875rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
...formStyles.input,
|
||||||
padding: "0.875rem 1rem",
|
|
||||||
fontSize: "1rem",
|
|
||||||
background: "rgba(255, 255, 255, 0.05)",
|
|
||||||
border: "1px solid rgba(255, 255, 255, 0.1)",
|
|
||||||
borderRadius: "12px",
|
|
||||||
color: "#fff",
|
|
||||||
outline: "none",
|
|
||||||
transition: "border-color 0.2s, box-shadow 0.2s",
|
|
||||||
},
|
},
|
||||||
button: {
|
button: {
|
||||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
...buttonStyles.primaryButton,
|
||||||
marginTop: "0.5rem",
|
marginTop: "0.5rem",
|
||||||
padding: "1rem",
|
|
||||||
fontSize: "1rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
background: "linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)",
|
|
||||||
color: "#fff",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "12px",
|
|
||||||
cursor: "pointer",
|
|
||||||
transition: "transform 0.2s, box-shadow 0.2s",
|
|
||||||
boxShadow: "0 4px 14px rgba(99, 102, 241, 0.4)",
|
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
...bannerStyles.errorBanner,
|
||||||
padding: "0.875rem 1rem",
|
|
||||||
background: "rgba(239, 68, 68, 0.1)",
|
|
||||||
border: "1px solid rgba(239, 68, 68, 0.3)",
|
|
||||||
borderRadius: "12px",
|
|
||||||
color: "#fca5a5",
|
|
||||||
fontSize: "0.875rem",
|
|
||||||
textAlign: "center" as const,
|
textAlign: "center" as const,
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Permission } from "../auth-context";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import { Header } from "../components/Header";
|
import { Header } from "../components/Header";
|
||||||
import { SatsDisplay } from "../components/SatsDisplay";
|
import { SatsDisplay } from "../components/SatsDisplay";
|
||||||
|
import { LoadingState } from "../components/LoadingState";
|
||||||
import { useRequireAuth } from "../hooks/useRequireAuth";
|
import { useRequireAuth } from "../hooks/useRequireAuth";
|
||||||
import { components } from "../generated/api";
|
import { components } from "../generated/api";
|
||||||
import { formatDateTime } from "../utils/date";
|
import { formatDateTime } from "../utils/date";
|
||||||
|
|
@ -68,11 +69,7 @@ export default function TradesPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<main style={layoutStyles.main}>
|
|
||||||
<div style={layoutStyles.loader}>Loading...</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthorized) {
|
if (!isAuthorized) {
|
||||||
|
|
|
||||||
50
frontend/app/utils/error-handling.ts
Normal file
50
frontend/app/utils/error-handling.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { ApiError } from "../api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a user-friendly error message from an API error or generic error.
|
||||||
|
* Handles ApiError instances with structured data, regular Error instances, and unknown errors.
|
||||||
|
*
|
||||||
|
* @param err - The error to extract a message from
|
||||||
|
* @param fallback - Default message if extraction fails (default: "An error occurred")
|
||||||
|
* @returns A user-friendly error message string
|
||||||
|
*/
|
||||||
|
export function extractApiErrorMessage(
|
||||||
|
err: unknown,
|
||||||
|
fallback: string = "An error occurred"
|
||||||
|
): string {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
if (err.data && typeof err.data === "object") {
|
||||||
|
const data = err.data as { detail?: string };
|
||||||
|
return data.detail || err.message || fallback;
|
||||||
|
}
|
||||||
|
return err.message || fallback;
|
||||||
|
}
|
||||||
|
if (err instanceof Error) {
|
||||||
|
return err.message;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to check if an error is an ApiError with structured detail data.
|
||||||
|
*/
|
||||||
|
export function isApiErrorWithDetail(
|
||||||
|
err: unknown
|
||||||
|
): err is ApiError & { data: { detail?: string } } {
|
||||||
|
return err instanceof ApiError && err.data !== undefined && typeof err.data === "object";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract field errors from a 422 validation error response.
|
||||||
|
* Returns undefined if the error doesn't contain field errors.
|
||||||
|
*/
|
||||||
|
export function extractFieldErrors(
|
||||||
|
err: unknown
|
||||||
|
): { detail?: { field_errors?: Record<string, string> } } | undefined {
|
||||||
|
if (err instanceof ApiError && err.status === 422) {
|
||||||
|
if (err.data && typeof err.data === "object") {
|
||||||
|
return err.data as { detail?: { field_errors?: Record<string, string> } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue