Compare commits

...

12 commits

Author SHA1 Message Date
6d0f125536
refactor(frontend): break down large Exchange page component
Break down the 1300+ line Exchange page into smaller, focused components:

- Create useExchangePrice hook
  - Handles price fetching and auto-refresh logic
  - Manages price loading and error states
  - Centralizes price-related state management

- Create useAvailableSlots hook
  - Manages slot fetching and availability checking
  - Handles date availability state
  - Fetches availability when entering booking/confirmation steps

- Create PriceDisplay component
  - Displays market price, agreed price, and premium
  - Shows price update timestamp and stale warnings
  - Handles loading and error states

- Create ExchangeDetailsStep component
  - Step 1 of wizard: direction, payment method, amount selection
  - Contains all form logic for trade details
  - Validates and displays trade summary

- Create BookingStep component
  - Step 2 of wizard: date and slot selection
  - Shows trade summary card
  - Handles date availability and existing trade warnings

- Create ConfirmationStep component
  - Step 3 of wizard: final confirmation
  - Shows compressed booking summary
  - Displays all trade details for review

- Create StepIndicator component
  - Visual indicator of current wizard step
  - Shows completed and active steps

- Refactor ExchangePage
  - Reduced from 1300+ lines to ~350 lines
  - Uses new hooks and components
  - Maintains all existing functionality
  - Improved maintainability and testability

All frontend tests pass. Linting passes.
2025-12-25 19:11:23 +01:00
3beb23a765
refactor(frontend): improve code quality and maintainability
- Extract API error handling utility (utils/error-handling.ts)
  - Centralize error message extraction logic
  - Add type guards for API errors
  - Replace duplicated error handling across components

- Create reusable Toast component (components/Toast.tsx)
  - Extract toast notification logic from profile page
  - Support auto-dismiss functionality
  - Consistent styling with shared styles

- Extract form validation debouncing hook (hooks/useDebouncedValidation.ts)
  - Reusable debounced validation logic
  - Clean timeout management
  - Used in profile page for form validation

- Consolidate duplicate styles (styles/auth-form.ts)
  - Use shared style tokens instead of duplicating values
  - Reduce code duplication between auth-form and shared styles

- Extract loading state component (components/LoadingState.tsx)
  - Standardize loading UI across pages
  - Replace duplicated loading JSX patterns
  - Used in profile, exchange, and trades pages

- Fix useRequireAuth dependency array
  - Remove unnecessary hasPermission from dependencies
  - Add eslint-disable comment with explanation
  - Improve hook stability and performance

All frontend tests pass. Linting passes.
2025-12-25 19:04:45 +01:00
db181b338c
Complete repository delegation - remove remaining direct db operations
- Add commit() method to AvailabilityRepository for transaction control
- Add refresh() method to UserRepository
- Update AvailabilityService to use repository.commit() instead of db.commit()
- Update AuthService to use UserRepository.refresh() instead of db.refresh()
- All services now consistently delegate ALL persistence to repositories
2025-12-25 18:57:55 +01:00
33aa8ad13b
Delegate exchange persistence to ExchangeRepository
- Add create() and update() methods to ExchangeRepository
- Update ExchangeService to use repository methods instead of direct db operations
- All persistence operations now go through repositories consistently
- Fix indentation errors in ExchangeService
2025-12-25 18:54:29 +01:00
c4594a3f73
Delegate invite persistence to InviteRepository
- Add create(), update(), and reload_with_relationships() methods to InviteRepository
- Update InviteService to use repository methods instead of direct db operations
2025-12-25 18:52:52 +01:00
04333d210b
Delegate user persistence to UserRepository
- Add create() and update() methods to UserRepository
- Update ProfileService to use repository.update()
- Update AuthService to use repository.create()
2025-12-25 18:52:23 +01:00
17aead2e21
Delegate availability persistence to AvailabilityRepository
- Add create() and create_multiple() methods to AvailabilityRepository
- Update AvailabilityService to use repository methods instead of direct db operations
2025-12-25 18:51:55 +01:00
4cb561d54f
Delegate price persistence to PriceRepository
- Add create() method to PriceRepository
- Update PriceService to use repository.create() instead of direct db operations
2025-12-25 18:51:24 +01:00
280c1e5687
Move slot expansion logic to ExchangeService
- Add get_available_slots() and _expand_availability_to_slots() to ExchangeService
- Update routes/exchange.py to use ExchangeService.get_available_slots()
- Remove all business logic from get_available_slots endpoint
- Add AvailabilityRepository to ExchangeService dependencies
- Add Availability and BookableSlot imports to ExchangeService
- Fix import path for validate_date_in_range (use date_validation module)
- Remove unused user_repo variable and import from routes/invites.py
- Fix mypy error in ValidationError by adding proper type annotation
2025-12-25 18:42:46 +01:00
c3a501e3b2
Extract availability logic to AvailabilityService
- Create AvailabilityService with get_availability_for_range(), set_availability_for_date(), and copy_availability()
- Move slot validation logic to service
- Update routes/availability.py to use AvailabilityService
- Remove all direct database queries from routes
2025-12-25 18:31:13 +01:00
badb45da59
Extract price logic to PriceService
- Create PriceService with get_recent_prices() and fetch_and_store_price()
- Update routes/audit.py to use PriceService instead of direct queries
- Use PriceHistoryMapper consistently
- Update test to patch services.price.fetch_btc_eur_price
2025-12-25 18:30:26 +01:00
168b67acee
refactors 2025-12-25 18:27:59 +01:00
44 changed files with 3414 additions and 1811 deletions

244
REFACTOR_PLAN.md Normal file
View 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

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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",
]

View 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()

View 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

View 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()

View file

@ -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

View 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()

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)
)

View file

@ -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)

View file

@ -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
View 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

View 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)

View file

@ -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
View 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
View 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

View 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,
)

View file

@ -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",

View file

@ -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:

View file

@ -0,0 +1 @@
"""Utility modules for common functionality."""

View 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)

View 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

View file

@ -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"));
}
};

View 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>
);
}

View 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>
);
}

View 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>
)}
</>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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,
};
}

View 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

View 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 };
}

View file

@ -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,

View file

@ -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" />

View file

@ -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: {

View file

@ -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) {

View 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;
}