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):
|
||||
"""Service unavailable error (503)."""
|
||||
|
||||
|
|
@ -59,3 +69,16 @@ class ServiceUnavailableError(APIError):
|
|||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
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."""
|
||||
|
||||
from models import Exchange, Invite
|
||||
from models import Exchange, Invite, PriceHistory
|
||||
from schemas import (
|
||||
AdminExchangeResponse,
|
||||
ExchangeResponse,
|
||||
ExchangeUserContact,
|
||||
InviteResponse,
|
||||
PriceHistoryResponse,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -89,3 +90,19 @@ class InviteMapper:
|
|||
spent_at=invite.spent_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."""
|
||||
|
||||
from repositories.availability import AvailabilityRepository
|
||||
from repositories.exchange import ExchangeRepository
|
||||
from repositories.invite import InviteRepository
|
||||
from repositories.price import PriceRepository
|
||||
from repositories.role import RoleRepository
|
||||
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."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import desc, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -25,3 +27,47 @@ class PriceRepository:
|
|||
)
|
||||
result = await self.db.execute(query)
|
||||
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."""
|
||||
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||
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."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import desc, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth import require_permission
|
||||
from database import get_db
|
||||
from models import Permission, PriceHistory, User
|
||||
from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX, fetch_btc_eur_price
|
||||
from mappers import PriceHistoryMapper
|
||||
from models import Permission, User
|
||||
from schemas import PriceHistoryResponse
|
||||
from services.price import PriceService
|
||||
|
||||
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_LIMIT = 20
|
||||
|
||||
|
||||
@router.get("/price-history", response_model=list[PriceHistoryResponse])
|
||||
async def get_price_history(
|
||||
|
|
@ -38,15 +24,10 @@ async def get_price_history(
|
|||
_current_user: User = Depends(require_permission(Permission.VIEW_AUDIT)),
|
||||
) -> list[PriceHistoryResponse]:
|
||||
"""Get the 20 most recent price history records."""
|
||||
query = (
|
||||
select(PriceHistory)
|
||||
.order_by(desc(PriceHistory.timestamp))
|
||||
.limit(PRICE_HISTORY_LIMIT)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
records = result.scalars().all()
|
||||
service = PriceService(db)
|
||||
records = await service.get_recent_prices()
|
||||
|
||||
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)
|
||||
|
|
@ -55,28 +36,7 @@ async def fetch_price_now(
|
|||
_current_user: User = Depends(require_permission(Permission.FETCH_PRICE)),
|
||||
) -> PriceHistoryResponse:
|
||||
"""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(
|
||||
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)
|
||||
return PriceHistoryMapper.to_response(record)
|
||||
|
|
|
|||
|
|
@ -1,26 +1,19 @@
|
|||
"""Authentication routes for register, login, logout, and current user."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy import select
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth import (
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
COOKIE_NAME,
|
||||
COOKIE_SECURE,
|
||||
authenticate_user,
|
||||
build_user_response,
|
||||
create_access_token,
|
||||
get_current_user,
|
||||
get_password_hash,
|
||||
get_user_by_email,
|
||||
)
|
||||
from database import get_db
|
||||
from invite_utils import normalize_identifier
|
||||
from models import ROLE_REGULAR, Invite, InviteStatus, Role, User
|
||||
from models import User
|
||||
from schemas import RegisterWithInvite, UserLogin, UserResponse
|
||||
from services.auth import AuthService
|
||||
|
||||
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)
|
||||
async def register(
|
||||
user_data: RegisterWithInvite,
|
||||
|
|
@ -50,51 +37,13 @@ async def register(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
) -> UserResponse:
|
||||
"""Register a new user using an invite code."""
|
||||
# Validate invite
|
||||
normalized_identifier = normalize_identifier(user_data.invite_identifier)
|
||||
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(
|
||||
service = AuthService(db)
|
||||
user, access_token = await service.register_user(
|
||||
email=user_data.email,
|
||||
hashed_password=get_password_hash(user_data.password),
|
||||
godfather_id=invite.godfather_id,
|
||||
password=user_data.password,
|
||||
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)
|
||||
return await build_user_response(user, db)
|
||||
|
||||
|
|
@ -106,14 +55,11 @@ async def login(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
) -> UserResponse:
|
||||
"""Authenticate a user and return their info with an auth cookie."""
|
||||
user = await authenticate_user(db, user_data.email, user_data.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password",
|
||||
service = AuthService(db)
|
||||
user, access_token = await service.login_user(
|
||||
email=user_data.email, password=user_data.password
|
||||
)
|
||||
|
||||
access_token = create_access_token(data={"sub": str(user.id)})
|
||||
set_auth_cookie(response, access_token)
|
||||
return await build_user_response(user, db)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,21 +2,19 @@
|
|||
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import and_, delete, select
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth import require_permission
|
||||
from database import get_db
|
||||
from date_validation import validate_date_in_range
|
||||
from models import Availability, Permission, User
|
||||
from models import Permission, User
|
||||
from schemas import (
|
||||
AvailabilityDay,
|
||||
AvailabilityResponse,
|
||||
CopyAvailabilityRequest,
|
||||
SetAvailabilityRequest,
|
||||
TimeSlot,
|
||||
)
|
||||
from services.availability import AvailabilityService
|
||||
|
||||
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)),
|
||||
) -> AvailabilityResponse:
|
||||
"""Get availability slots for a date range."""
|
||||
if from_date > to_date:
|
||||
raise HTTPException(
|
||||
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)
|
||||
service = AvailabilityService(db)
|
||||
return await service.get_availability_for_range(from_date, to_date)
|
||||
|
||||
|
||||
@router.put("", response_model=AvailabilityDay)
|
||||
|
|
@ -70,44 +38,8 @@ async def set_availability(
|
|||
_current_user: User = Depends(require_permission(Permission.MANAGE_AVAILABILITY)),
|
||||
) -> AvailabilityDay:
|
||||
"""Set availability for a specific date. Replaces any existing availability."""
|
||||
validate_date_in_range(request.date, context="set availability")
|
||||
|
||||
# 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)
|
||||
service = AvailabilityService(db)
|
||||
return await service.set_availability_for_date(request.date, request.slots)
|
||||
|
||||
|
||||
@router.post("/copy", response_model=AvailabilityResponse)
|
||||
|
|
@ -117,62 +49,5 @@ async def copy_availability(
|
|||
_current_user: User = Depends(require_permission(Permission.MANAGE_AVAILABILITY)),
|
||||
) -> AvailabilityResponse:
|
||||
"""Copy availability from one day to multiple target days."""
|
||||
# Validate source date is in range
|
||||
validate_date_in_range(request.source_date, context="copy from")
|
||||
|
||||
# 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)
|
||||
service = AvailabilityService(db)
|
||||
return await service.copy_availability(request.source_date, request.target_dates)
|
||||
|
|
|
|||
|
|
@ -1,22 +1,16 @@
|
|||
"""Exchange routes for Bitcoin trading."""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from auth import require_permission
|
||||
from database import get_db
|
||||
from date_validation import validate_date_in_range
|
||||
from exceptions import BadRequestError
|
||||
from mappers import ExchangeMapper
|
||||
from models import (
|
||||
Availability,
|
||||
BitcoinTransferMethod,
|
||||
Exchange,
|
||||
ExchangeStatus,
|
||||
Permission,
|
||||
PriceHistory,
|
||||
|
|
@ -24,11 +18,11 @@ from models import (
|
|||
User,
|
||||
)
|
||||
from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX, fetch_btc_eur_price
|
||||
from repositories.exchange import ExchangeRepository
|
||||
from repositories.price import PriceRepository
|
||||
from schemas import (
|
||||
AdminExchangeResponse,
|
||||
AvailableSlotsResponse,
|
||||
BookableSlot,
|
||||
ExchangeConfigResponse,
|
||||
ExchangePriceResponse,
|
||||
ExchangeRequest,
|
||||
|
|
@ -42,8 +36,8 @@ from shared_constants import (
|
|||
EUR_TRADE_MAX,
|
||||
EUR_TRADE_MIN,
|
||||
PREMIUM_PERCENTAGE,
|
||||
SLOT_DURATION_MINUTES,
|
||||
)
|
||||
from utils.enum_validation import validate_enum
|
||||
|
||||
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)
|
||||
async def get_available_slots(
|
||||
date_param: date = Query(..., alias="date"),
|
||||
|
|
@ -187,42 +157,8 @@ async def get_available_slots(
|
|||
- Fall within admin-defined availability windows
|
||||
- Are not already booked by another user
|
||||
"""
|
||||
validate_date_in_range(date_param, context="book")
|
||||
|
||||
# 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)
|
||||
service = ExchangeService(db)
|
||||
return await service.get_available_slots(date_param)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
@ -247,21 +183,16 @@ async def create_exchange(
|
|||
- EUR amount is within configured limits
|
||||
"""
|
||||
# Validate direction
|
||||
try:
|
||||
direction = TradeDirection(request.direction)
|
||||
except ValueError:
|
||||
raise BadRequestError(
|
||||
f"Invalid direction: {request.direction}. Must be 'buy' or 'sell'."
|
||||
) from None
|
||||
direction: TradeDirection = validate_enum(
|
||||
TradeDirection, request.direction, "direction"
|
||||
)
|
||||
|
||||
# Validate bitcoin transfer method
|
||||
try:
|
||||
bitcoin_transfer_method = BitcoinTransferMethod(request.bitcoin_transfer_method)
|
||||
except ValueError:
|
||||
raise BadRequestError(
|
||||
f"Invalid bitcoin_transfer_method: {request.bitcoin_transfer_method}. "
|
||||
"Must be 'onchain' or 'lightning'."
|
||||
) from None
|
||||
bitcoin_transfer_method: BitcoinTransferMethod = validate_enum(
|
||||
BitcoinTransferMethod,
|
||||
request.bitcoin_transfer_method,
|
||||
"bitcoin_transfer_method",
|
||||
)
|
||||
|
||||
# Use service to create exchange (handles all validation)
|
||||
service = ExchangeService(db)
|
||||
|
|
@ -289,12 +220,8 @@ async def get_my_trades(
|
|||
current_user: User = Depends(require_permission(Permission.VIEW_OWN_EXCHANGES)),
|
||||
) -> list[ExchangeResponse]:
|
||||
"""Get the current user's exchanges, sorted by date (newest first)."""
|
||||
result = await db.execute(
|
||||
select(Exchange)
|
||||
.where(Exchange.user_id == current_user.id)
|
||||
.order_by(Exchange.slot_start.desc())
|
||||
)
|
||||
exchanges = result.scalars().all()
|
||||
exchange_repo = ExchangeRepository(db)
|
||||
exchanges = await exchange_repo.get_by_user_id(current_user.id, order_by_desc=True)
|
||||
|
||||
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)),
|
||||
) -> list[AdminExchangeResponse]:
|
||||
"""Get all upcoming booked trades, sorted by slot time ascending."""
|
||||
now = datetime.now(UTC)
|
||||
result = await db.execute(
|
||||
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()
|
||||
exchange_repo = ExchangeRepository(db)
|
||||
exchanges = await exchange_repo.get_upcoming_booked()
|
||||
|
||||
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)
|
||||
"""
|
||||
|
||||
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
|
||||
status_enum: ExchangeStatus | None = None
|
||||
if status:
|
||||
try:
|
||||
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
|
||||
status_enum = validate_enum(ExchangeStatus, 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 (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()
|
||||
# Use repository for query
|
||||
exchange_repo = ExchangeRepository(db)
|
||||
exchanges = await exchange_repo.get_past_trades(
|
||||
status=status_enum,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
user_search=user_search,
|
||||
)
|
||||
|
||||
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).
|
||||
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(
|
||||
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."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth import require_permission
|
||||
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 models import Invite, InviteStatus, Permission, User
|
||||
from pagination import calculate_offset, create_paginated_response
|
||||
from models import Permission, User
|
||||
from schemas import (
|
||||
AdminUserResponse,
|
||||
InviteCheckResponse,
|
||||
|
|
@ -26,12 +16,11 @@ from schemas import (
|
|||
PaginatedInviteRecords,
|
||||
UserInviteResponse,
|
||||
)
|
||||
from services.invite import InviteService
|
||||
|
||||
router = APIRouter(prefix="/api/invites", tags=["invites"])
|
||||
admin_router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
MAX_INVITE_COLLISION_RETRIES = 3
|
||||
|
||||
|
||||
@router.get("/{identifier}/check", response_model=InviteCheckResponse)
|
||||
async def check_invite(
|
||||
|
|
@ -39,20 +28,8 @@ async def check_invite(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
) -> InviteCheckResponse:
|
||||
"""Check if an invite is valid and can be used for signup."""
|
||||
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")
|
||||
|
||||
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)
|
||||
service = InviteService(db)
|
||||
return await service.check_invite_validity(identifier)
|
||||
|
||||
|
||||
@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)),
|
||||
) -> list[UserInviteResponse]:
|
||||
"""Get all invites owned by the current user."""
|
||||
result = await db.execute(
|
||||
select(Invite)
|
||||
.where(Invite.godfather_id == current_user.id)
|
||||
.order_by(desc(Invite.created_at))
|
||||
)
|
||||
invites = result.scalars().all()
|
||||
service = InviteService(db)
|
||||
invites = await service.get_user_invites(current_user.id)
|
||||
|
||||
# Use preloaded used_by relationship (selectin loading)
|
||||
return [
|
||||
UserInviteResponse(
|
||||
id=invite.id,
|
||||
|
|
@ -88,6 +60,8 @@ async def list_users_for_admin(
|
|||
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
|
||||
) -> list[AdminUserResponse]:
|
||||
"""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))
|
||||
users = result.all()
|
||||
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)),
|
||||
) -> InviteResponse:
|
||||
"""Create a new invite for a specified godfather user."""
|
||||
# Validate godfather exists
|
||||
result = await db.execute(select(User.id).where(User.id == 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",
|
||||
)
|
||||
service = InviteService(db)
|
||||
invite = await service.create_invite(data.godfather_id)
|
||||
return InviteMapper.to_response(invite)
|
||||
|
||||
|
||||
|
|
@ -148,41 +91,13 @@ async def list_all_invites(
|
|||
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
|
||||
) -> PaginatedInviteRecords:
|
||||
"""List all invites with optional filtering and pagination."""
|
||||
# Build query
|
||||
query = select(Invite)
|
||||
count_query = select(func.count(Invite.id))
|
||||
|
||||
# Apply filters
|
||||
if status_filter:
|
||||
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)
|
||||
service = InviteService(db)
|
||||
return await service.list_invites(
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
status_filter=status_filter,
|
||||
godfather_id=godfather_id,
|
||||
)
|
||||
|
||||
|
||||
@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)),
|
||||
) -> InviteResponse:
|
||||
"""Revoke an invite. Only READY invites can be revoked."""
|
||||
result = await db.execute(select(Invite).where(Invite.id == invite_id))
|
||||
invite = result.scalar_one_or_none()
|
||||
|
||||
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)
|
||||
|
||||
service = InviteService(db)
|
||||
invite = await service.revoke_invite(invite_id)
|
||||
return InviteMapper.to_response(invite)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,41 +1,25 @@
|
|||
"""Profile routes for user contact details."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth import require_permission
|
||||
from database import get_db
|
||||
from models import Permission, User
|
||||
from schemas import ProfileResponse, ProfileUpdate
|
||||
from validation import validate_profile_fields
|
||||
from services.profile import ProfileService
|
||||
|
||||
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)
|
||||
async def get_profile(
|
||||
current_user: User = Depends(require_permission(Permission.MANAGE_OWN_PROFILE)),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ProfileResponse:
|
||||
"""Get the current user's profile (contact details and godfather)."""
|
||||
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,
|
||||
)
|
||||
service = ProfileService(db)
|
||||
return await service.get_profile(current_user)
|
||||
|
||||
|
||||
@router.put("", response_model=ProfileResponse)
|
||||
|
|
@ -45,36 +29,5 @@ async def update_profile(
|
|||
current_user: User = Depends(require_permission(Permission.MANAGE_OWN_PROFILE)),
|
||||
) -> ProfileResponse:
|
||||
"""Update the current user's profile (contact details)."""
|
||||
# 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 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,
|
||||
)
|
||||
service = ProfileService(db)
|
||||
return await service.update_profile(current_user, data)
|
||||
|
|
|
|||
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."""
|
||||
|
||||
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.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -23,7 +22,10 @@ from models import (
|
|||
TradeDirection,
|
||||
User,
|
||||
)
|
||||
from repositories.availability import AvailabilityRepository
|
||||
from repositories.exchange import ExchangeRepository
|
||||
from repositories.price import PriceRepository
|
||||
from schemas import AvailableSlotsResponse, BookableSlot
|
||||
from shared_constants import (
|
||||
EUR_TRADE_INCREMENT,
|
||||
EUR_TRADE_MAX,
|
||||
|
|
@ -44,6 +46,8 @@ class ExchangeService:
|
|||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.price_repo = PriceRepository(db)
|
||||
self.exchange_repo = ExchangeRepository(db)
|
||||
self.availability_repo = AvailabilityRepository(db)
|
||||
|
||||
def apply_premium_for_direction(
|
||||
self,
|
||||
|
|
@ -107,20 +111,21 @@ class ExchangeService:
|
|||
self, slot_start: datetime, slot_date: date
|
||||
) -> None:
|
||||
"""Verify slot falls within availability."""
|
||||
from repositories.availability import AvailabilityRepository
|
||||
|
||||
slot_start_time = slot_start.time()
|
||||
slot_end_dt = slot_start + timedelta(minutes=SLOT_DURATION_MINUTES)
|
||||
slot_end_time = slot_end_dt.time()
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Availability).where(
|
||||
and_(
|
||||
Availability.date == slot_date,
|
||||
Availability.start_time <= slot_start_time,
|
||||
Availability.end_time >= slot_end_time,
|
||||
)
|
||||
)
|
||||
)
|
||||
matching_availability = result.scalar_one_or_none()
|
||||
availability_repo = AvailabilityRepository(self.db)
|
||||
availabilities = await availability_repo.get_by_date(slot_date)
|
||||
|
||||
# Check if any availability block contains this slot
|
||||
matching_availability = None
|
||||
for avail in availabilities:
|
||||
if avail.start_time <= slot_start_time and avail.end_time >= slot_end_time:
|
||||
matching_availability = avail
|
||||
break
|
||||
|
||||
if not matching_availability:
|
||||
slot_str = slot_start.strftime("%Y-%m-%d %H:%M")
|
||||
|
|
@ -171,29 +176,19 @@ class ExchangeService:
|
|||
self, user: User, slot_date: date
|
||||
) -> Exchange | None:
|
||||
"""Check if user already has a trade on this date."""
|
||||
existing_trade_query = select(Exchange).where(
|
||||
and_(
|
||||
Exchange.user_id == user.id,
|
||||
Exchange.slot_start
|
||||
>= datetime.combine(slot_date, time.min, tzinfo=UTC),
|
||||
Exchange.slot_start
|
||||
< datetime.combine(slot_date, time.max, tzinfo=UTC) + timedelta(days=1),
|
||||
Exchange.status == ExchangeStatus.BOOKED,
|
||||
exchanges = await self.exchange_repo.get_by_user_and_date_range(
|
||||
user_id=user.id,
|
||||
start_date=slot_date,
|
||||
end_date=slot_date,
|
||||
status=ExchangeStatus.BOOKED,
|
||||
)
|
||||
)
|
||||
result = await self.db.execute(existing_trade_query)
|
||||
return result.scalar_one_or_none()
|
||||
return exchanges[0] if exchanges else None
|
||||
|
||||
async def check_slot_already_booked(self, slot_start: datetime) -> Exchange | None:
|
||||
"""Check if slot is already booked (only consider BOOKED status)."""
|
||||
slot_booked_query = select(Exchange).where(
|
||||
and_(
|
||||
Exchange.slot_start == slot_start,
|
||||
Exchange.status == ExchangeStatus.BOOKED,
|
||||
return await self.exchange_repo.get_by_slot_start(
|
||||
slot_start, status=ExchangeStatus.BOOKED
|
||||
)
|
||||
)
|
||||
result = await self.db.execute(slot_booked_query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create_exchange(
|
||||
self,
|
||||
|
|
@ -272,11 +267,8 @@ class ExchangeService:
|
|||
status=ExchangeStatus.BOOKED,
|
||||
)
|
||||
|
||||
self.db.add(exchange)
|
||||
|
||||
try:
|
||||
await self.db.commit()
|
||||
await self.db.refresh(exchange)
|
||||
return await self.exchange_repo.create(exchange)
|
||||
except IntegrityError as e:
|
||||
await self.db.rollback()
|
||||
# This should rarely happen now since we check explicitly above,
|
||||
|
|
@ -285,8 +277,6 @@ class ExchangeService:
|
|||
"Database constraint violation. Please try again."
|
||||
) from e
|
||||
|
||||
return exchange
|
||||
|
||||
async def get_exchange_by_public_id(
|
||||
self, public_id: uuid.UUID, user: User | None = None
|
||||
) -> Exchange:
|
||||
|
|
@ -297,9 +287,7 @@ class ExchangeService:
|
|||
NotFoundError: If exchange not found or user doesn't own it
|
||||
(for security, returns 404)
|
||||
"""
|
||||
query = select(Exchange).where(Exchange.public_id == public_id)
|
||||
result = await self.db.execute(query)
|
||||
exchange = result.scalar_one_or_none()
|
||||
exchange = await self.exchange_repo.get_by_public_id(public_id)
|
||||
|
||||
if not exchange:
|
||||
raise NotFoundError("Trade")
|
||||
|
|
@ -338,10 +326,7 @@ class ExchangeService:
|
|||
)
|
||||
exchange.cancelled_at = datetime.now(UTC)
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(exchange)
|
||||
|
||||
return exchange
|
||||
return await self.exchange_repo.update(exchange)
|
||||
|
||||
async def complete_exchange(self, exchange: Exchange) -> Exchange:
|
||||
"""
|
||||
|
|
@ -361,10 +346,7 @@ class ExchangeService:
|
|||
exchange.status = ExchangeStatus.COMPLETED
|
||||
exchange.completed_at = datetime.now(UTC)
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(exchange)
|
||||
|
||||
return exchange
|
||||
return await self.exchange_repo.update(exchange)
|
||||
|
||||
async def mark_no_show(self, exchange: Exchange) -> Exchange:
|
||||
"""
|
||||
|
|
@ -386,7 +368,74 @@ class ExchangeService:
|
|||
exchange.status = ExchangeStatus.NO_SHOW
|
||||
exchange.completed_at = datetime.now(UTC)
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(exchange)
|
||||
return await self.exchange_repo.update(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
|
||||
|
||||
with patch(
|
||||
"routes.invites.generate_invite_identifier", side_effect=mock_generator
|
||||
"services.invite.generate_invite_identifier", side_effect=mock_generator
|
||||
):
|
||||
response2 = await client.post(
|
||||
"/api/admin/invites",
|
||||
|
|
|
|||
|
|
@ -280,7 +280,7 @@ class TestManualFetch:
|
|||
existing_id = existing.id
|
||||
|
||||
# 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)
|
||||
|
||||
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 { api, ApiError } from "./api";
|
||||
import { api } from "./api";
|
||||
import { components } from "./generated/api";
|
||||
import { extractApiErrorMessage } from "./utils/error-handling";
|
||||
|
||||
// Permission type from generated OpenAPI schema
|
||||
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 });
|
||||
setUser(userData);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
const data = err.data as { detail?: string };
|
||||
throw new Error(data?.detail || "Login failed");
|
||||
}
|
||||
throw err;
|
||||
throw new Error(extractApiErrorMessage(err, "Login failed"));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -84,11 +81,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
});
|
||||
setUser(userData);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
const data = err.data as { detail?: string };
|
||||
throw new Error(data?.detail || "Registration failed");
|
||||
}
|
||||
throw err;
|
||||
throw new Error(extractApiErrorMessage(err, "Registration failed"));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
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) {
|
||||
// Redirect to the most appropriate page based on permissions
|
||||
// Use hasPermission/hasRole directly since they're stable callbacks
|
||||
const redirect =
|
||||
fallbackRedirect ??
|
||||
(hasPermission(Permission.VIEW_ALL_EXCHANGES)
|
||||
|
|
@ -55,7 +56,11 @@ export function useRequireAuth(options: UseRequireAuthOptions = {}): UseRequireA
|
|||
: "/login");
|
||||
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 {
|
||||
user,
|
||||
|
|
|
|||
|
|
@ -1,21 +1,24 @@
|
|||
"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 { Header } from "../components/Header";
|
||||
import { Toast } from "../components/Toast";
|
||||
import { LoadingState } from "../components/LoadingState";
|
||||
import { components } from "../generated/api";
|
||||
import { useRequireAuth } from "../hooks/useRequireAuth";
|
||||
import { useDebouncedValidation } from "../hooks/useDebouncedValidation";
|
||||
import {
|
||||
layoutStyles,
|
||||
cardStyles,
|
||||
formStyles,
|
||||
buttonStyles,
|
||||
toastStyles,
|
||||
utilityStyles,
|
||||
} from "../styles/shared";
|
||||
import { FieldErrors, validateProfileFields } from "../utils/validation";
|
||||
import { validateProfileFields } from "../utils/validation";
|
||||
|
||||
// Use generated type from OpenAPI schema
|
||||
type ProfileData = components["schemas"]["ProfileResponse"];
|
||||
|
|
@ -50,11 +53,15 @@ export default function ProfilePage() {
|
|||
nostr_npub: "",
|
||||
});
|
||||
const [godfatherEmail, setGodfatherEmail] = useState<string | null>(null);
|
||||
const [errors, setErrors] = useState<FieldErrors>({});
|
||||
const [isLoadingProfile, setIsLoadingProfile] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
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
|
||||
const hasChanges = useCallback(() => {
|
||||
|
|
@ -93,23 +100,6 @@ export default function ProfilePage() {
|
|||
}
|
||||
}, [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>) => {
|
||||
let value = e.target.value;
|
||||
|
||||
|
|
@ -121,19 +111,11 @@ export default function ProfilePage() {
|
|||
}
|
||||
}
|
||||
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
|
||||
// Clear any pending validation timeout
|
||||
if (validationTimeoutRef.current) {
|
||||
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);
|
||||
setFormData(newFormData);
|
||||
|
||||
// Trigger debounced validation with the new data
|
||||
validateForm(newFormData);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
|
|
@ -162,14 +144,15 @@ export default function ProfilePage() {
|
|||
setToast({ message: "Profile saved successfully!", type: "success" });
|
||||
} catch (err) {
|
||||
console.error("Profile save error:", err);
|
||||
if (err instanceof ApiError && err.status === 422) {
|
||||
const errorData = err.data as { detail?: { field_errors?: FieldErrors } };
|
||||
if (errorData?.detail?.field_errors) {
|
||||
setErrors(errorData.detail.field_errors);
|
||||
}
|
||||
const fieldErrors = extractFieldErrors(err);
|
||||
if (fieldErrors?.detail?.field_errors) {
|
||||
setErrors(fieldErrors.detail.field_errors);
|
||||
setToast({ message: "Please fix the errors below", type: "error" });
|
||||
} else {
|
||||
setToast({ message: "Network error. Please try again.", type: "error" });
|
||||
setToast({
|
||||
message: extractApiErrorMessage(err, "Network error. Please try again."),
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
|
|
@ -177,11 +160,7 @@ export default function ProfilePage() {
|
|||
};
|
||||
|
||||
if (isLoading || isLoadingProfile) {
|
||||
return (
|
||||
<main style={layoutStyles.main}>
|
||||
<div style={layoutStyles.loader}>Loading...</div>
|
||||
</main>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (!user || !isAuthorized) {
|
||||
|
|
@ -194,14 +173,7 @@ export default function ProfilePage() {
|
|||
<main style={layoutStyles.main}>
|
||||
{/* Toast notification */}
|
||||
{toast && (
|
||||
<div
|
||||
style={{
|
||||
...toastStyles.toast,
|
||||
...(toast.type === "success" ? toastStyles.toastSuccess : toastStyles.toastError),
|
||||
}}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
<Toast message={toast.message} type={toast.type} onDismiss={() => setToast(null)} />
|
||||
)}
|
||||
|
||||
<Header currentPage="profile" />
|
||||
|
|
|
|||
|
|
@ -1,12 +1,21 @@
|
|||
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> = {
|
||||
main: {
|
||||
...layoutStyles.contentCentered,
|
||||
minHeight: "100vh",
|
||||
background: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #2d1b4e 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "1rem",
|
||||
},
|
||||
container: {
|
||||
|
|
@ -14,80 +23,41 @@ export const authFormStyles: Record<string, CSSProperties> = {
|
|||
maxWidth: "420px",
|
||||
},
|
||||
card: {
|
||||
background: "rgba(255, 255, 255, 0.03)",
|
||||
backdropFilter: "blur(10px)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.08)",
|
||||
borderRadius: "24px",
|
||||
...cardStyles.card,
|
||||
padding: "3rem 2.5rem",
|
||||
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)",
|
||||
},
|
||||
header: {
|
||||
textAlign: "center" as const,
|
||||
marginBottom: "2.5rem",
|
||||
},
|
||||
title: {
|
||||
fontFamily: "'Instrument Serif', Georgia, serif",
|
||||
...typographyStyles.pageTitle,
|
||||
fontSize: "2.5rem",
|
||||
fontWeight: 400,
|
||||
color: "#fff",
|
||||
margin: 0,
|
||||
letterSpacing: "-0.02em",
|
||||
textAlign: "center" as const,
|
||||
},
|
||||
subtitle: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
color: "rgba(255, 255, 255, 0.5)",
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.95rem",
|
||||
...typographyStyles.pageSubtitle,
|
||||
textAlign: "center" as const,
|
||||
},
|
||||
form: {
|
||||
display: "flex",
|
||||
flexDirection: "column" as const,
|
||||
...formStyles.form,
|
||||
gap: "1.5rem",
|
||||
},
|
||||
field: {
|
||||
display: "flex",
|
||||
flexDirection: "column" as const,
|
||||
gap: "0.5rem",
|
||||
...formStyles.field,
|
||||
},
|
||||
label: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
color: "rgba(255, 255, 255, 0.7)",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 500,
|
||||
...formStyles.label,
|
||||
},
|
||||
input: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
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",
|
||||
...formStyles.input,
|
||||
},
|
||||
button: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
...buttonStyles.primaryButton,
|
||||
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: {
|
||||
fontFamily: "'DM Sans', system-ui, sans-serif",
|
||||
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",
|
||||
...bannerStyles.errorBanner,
|
||||
textAlign: "center" as const,
|
||||
},
|
||||
footer: {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Permission } from "../auth-context";
|
|||
import { api } from "../api";
|
||||
import { Header } from "../components/Header";
|
||||
import { SatsDisplay } from "../components/SatsDisplay";
|
||||
import { LoadingState } from "../components/LoadingState";
|
||||
import { useRequireAuth } from "../hooks/useRequireAuth";
|
||||
import { components } from "../generated/api";
|
||||
import { formatDateTime } from "../utils/date";
|
||||
|
|
@ -68,11 +69,7 @@ export default function TradesPage() {
|
|||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<main style={layoutStyles.main}>
|
||||
<div style={layoutStyles.loader}>Loading...</div>
|
||||
</main>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
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