diff --git a/REFACTOR_PLAN.md b/REFACTOR_PLAN.md new file mode 100644 index 0000000..76cb3ba --- /dev/null +++ b/REFACTOR_PLAN.md @@ -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 + diff --git a/backend/exceptions.py b/backend/exceptions.py index 7e8a641..4435fbf 100644 --- a/backend/exceptions.py +++ b/backend/exceptions.py @@ -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, + ) diff --git a/backend/mappers.py b/backend/mappers.py index 8f2ad4a..5ebd8fa 100644 --- a/backend/mappers.py +++ b/backend/mappers.py @@ -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, + ) diff --git a/backend/repositories/__init__.py b/backend/repositories/__init__.py index aff0836..805dee7 100644 --- a/backend/repositories/__init__.py +++ b/backend/repositories/__init__.py @@ -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", +] diff --git a/backend/repositories/availability.py b/backend/repositories/availability.py new file mode 100644 index 0000000..b530c0e --- /dev/null +++ b/backend/repositories/availability.py @@ -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() diff --git a/backend/repositories/exchange.py b/backend/repositories/exchange.py new file mode 100644 index 0000000..81160c9 --- /dev/null +++ b/backend/repositories/exchange.py @@ -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 diff --git a/backend/repositories/invite.py b/backend/repositories/invite.py new file mode 100644 index 0000000..4f150fc --- /dev/null +++ b/backend/repositories/invite.py @@ -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() diff --git a/backend/repositories/price.py b/backend/repositories/price.py index b8322da..bf6a566 100644 --- a/backend/repositories/price.py +++ b/backend/repositories/price.py @@ -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 diff --git a/backend/repositories/role.py b/backend/repositories/role.py new file mode 100644 index 0000000..c56c734 --- /dev/null +++ b/backend/repositories/role.py @@ -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() diff --git a/backend/repositories/user.py b/backend/repositories/user.py index d4c12ce..f930427 100644 --- a/backend/repositories/user.py +++ b/backend/repositories/user.py @@ -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) diff --git a/backend/routes/audit.py b/backend/routes/audit.py index 0cc725b..30c44b7 100644 --- a/backend/routes/audit.py +++ b/backend/routes/audit.py @@ -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) diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 604b50d..f228ab1 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -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) diff --git a/backend/routes/availability.py b/backend/routes/availability.py index cd21d56..84b8c97 100644 --- a/backend/routes/availability.py +++ b/backend/routes/availability.py @@ -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) diff --git a/backend/routes/exchange.py b/backend/routes/exchange.py index 96879af..95e20ea 100644 --- a/backend/routes/exchange.py +++ b/backend/routes/exchange.py @@ -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) ) diff --git a/backend/routes/invites.py b/backend/routes/invites.py index 7e50472..812ab3c 100644 --- a/backend/routes/invites.py +++ b/backend/routes/invites.py @@ -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) diff --git a/backend/routes/profile.py b/backend/routes/profile.py index bd294cb..5c474cf 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -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) diff --git a/backend/services/auth.py b/backend/services/auth.py new file mode 100644 index 0000000..9bb64a3 --- /dev/null +++ b/backend/services/auth.py @@ -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 diff --git a/backend/services/availability.py b/backend/services/availability.py new file mode 100644 index 0000000..438b5c5 --- /dev/null +++ b/backend/services/availability.py @@ -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) diff --git a/backend/services/exchange.py b/backend/services/exchange.py index cf1f641..05feaa9 100644 --- a/backend/services/exchange.py +++ b/backend/services/exchange.py @@ -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) diff --git a/backend/services/invite.py b/backend/services/invite.py new file mode 100644 index 0000000..9e71782 --- /dev/null +++ b/backend/services/invite.py @@ -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) diff --git a/backend/services/price.py b/backend/services/price.py new file mode 100644 index 0000000..4d1864e --- /dev/null +++ b/backend/services/price.py @@ -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 diff --git a/backend/services/profile.py b/backend/services/profile.py new file mode 100644 index 0000000..447022f --- /dev/null +++ b/backend/services/profile.py @@ -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, + ) diff --git a/backend/tests/test_invites.py b/backend/tests/test_invites.py index 20042ea..e63fa5b 100644 --- a/backend/tests/test_invites.py +++ b/backend/tests/test_invites.py @@ -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", diff --git a/backend/tests/test_price_history.py b/backend/tests/test_price_history.py index 4de7b6f..39861bb 100644 --- a/backend/tests/test_price_history.py +++ b/backend/tests/test_price_history.py @@ -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: diff --git a/backend/utils/__init__.py b/backend/utils/__init__.py new file mode 100644 index 0000000..a245cc5 --- /dev/null +++ b/backend/utils/__init__.py @@ -0,0 +1 @@ +"""Utility modules for common functionality.""" diff --git a/backend/utils/date_queries.py b/backend/utils/date_queries.py new file mode 100644 index 0000000..71f5051 --- /dev/null +++ b/backend/utils/date_queries.py @@ -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) diff --git a/backend/utils/enum_validation.py b/backend/utils/enum_validation.py new file mode 100644 index 0000000..2b4b78b --- /dev/null +++ b/backend/utils/enum_validation.py @@ -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 diff --git a/frontend/app/auth-context.tsx b/frontend/app/auth-context.tsx index 7b23511..85b79f8 100644 --- a/frontend/app/auth-context.tsx +++ b/frontend/app/auth-context.tsx @@ -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("/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")); } }; diff --git a/frontend/app/components/LoadingState.tsx b/frontend/app/components/LoadingState.tsx new file mode 100644 index 0000000..ff92367 --- /dev/null +++ b/frontend/app/components/LoadingState.tsx @@ -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 ( +
+
{message}
+
+ ); +} diff --git a/frontend/app/components/Toast.tsx b/frontend/app/components/Toast.tsx new file mode 100644 index 0000000..5e9cbfb --- /dev/null +++ b/frontend/app/components/Toast.tsx @@ -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 ( +
+ {message} +
+ ); +} diff --git a/frontend/app/exchange/components/BookingStep.tsx b/frontend/app/exchange/components/BookingStep.tsx new file mode 100644 index 0000000..e84f076 --- /dev/null +++ b/frontend/app/exchange/components/BookingStep.tsx @@ -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; + isLoadingSlots: boolean; + isLoadingAvailability: boolean; + existingTradeOnSelectedDate: ExchangeResponse | null; + userTrades: ExchangeResponse[]; + onDateSelect: (date: Date) => void; + onSlotSelect: (slot: BookableSlot) => void; + onBackToDetails: () => void; +} + +const styles: Record = { + 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 */} +
+
+ Your Exchange + +
+
+ + {direction === "buy" ? "Buy" : "Sell"} BTC + + + {formatEur(eurAmount)} + + + + + + + {direction === "buy" ? "Receive via " : "Send via "} + {bitcoinTransferMethod === "onchain" ? "Onchain" : "Lightning"} + +
+
+ + {/* Date Selection */} +
+

Select a Date

+
+ {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 ( + + ); + })} +
+
+ + {/* Warning for existing trade on selected date */} + {existingTradeOnSelectedDate && ( +
+
+ You already have a trade booked on this day. You can only book one trade per day. +
+
+ + View your existing trade → + +
+
+ )} + + {/* Available Slots */} + {selectedDate && !existingTradeOnSelectedDate && ( +
+

+ Available Slots for{" "} + {selectedDate.toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + })} +

+ + {isLoadingSlots ? ( +
Loading slots...
+ ) : availableSlots.length === 0 ? ( +
No available slots for this date
+ ) : ( +
+ {availableSlots.map((slot) => { + const isSelected = selectedSlot?.start_time === slot.start_time; + return ( + + ); + })} +
+ )} +
+ )} + + ); +} diff --git a/frontend/app/exchange/components/ConfirmationStep.tsx b/frontend/app/exchange/components/ConfirmationStep.tsx new file mode 100644 index 0000000..1cb0197 --- /dev/null +++ b/frontend/app/exchange/components/ConfirmationStep.tsx @@ -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 = { + 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 */} +
+
+ Appointment + +
+
+ + {selectedDate?.toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + })} + + + + {formatTime(selectedSlot.start_time)} - {formatTime(selectedSlot.end_time)} + +
+
+ + {/* Confirmation Card */} +
+

Confirm Trade

+
+
+ Time: + + {formatTime(selectedSlot.start_time)} - {formatTime(selectedSlot.end_time)} + +
+
+ Direction: + + {direction === "buy" ? "Buy BTC" : "Sell BTC"} + +
+
+ EUR: + {formatEur(eurAmount)} +
+
+ BTC: + + + +
+
+ Rate: + {formatPrice(agreedPrice)}/BTC +
+
+ Payment: + + {direction === "buy" ? "Receive via " : "Send via "} + {bitcoinTransferMethod === "onchain" ? "Onchain" : "Lightning"} + +
+
+ +
+ + +
+
+ + ); +} diff --git a/frontend/app/exchange/components/ExchangeDetailsStep.tsx b/frontend/app/exchange/components/ExchangeDetailsStep.tsx new file mode 100644 index 0000000..3219baf --- /dev/null +++ b/frontend/app/exchange/components/ExchangeDetailsStep.tsx @@ -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 = { + 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) => { + const inputValue = e.target.value.replace(/[^0-9]/g, ""); + if (inputValue === "") { + onEurAmountChange(eurMin * 100); + return; + } + const eurValue = parseInt(inputValue, 10); + handleAmountChange(eurValue * 100); + }; + + return ( +
+ {/* Direction Selector */} +
+ + +
+ + {/* Payment Method Selector */} +
+
+ Payment Method * +
+
+ + +
+ {isLightningDisabled && ( +
+ Lightning payments are only available for amounts up to €{LIGHTNING_MAX_EUR} +
+ )} +
+ + {/* Amount Section */} +
+
+ Amount (EUR) +
+ + +
+
+ onEurAmountChange(Number(e.target.value))} + style={styles.slider} + /> +
+ {formatEur(eurMin * 100)} + {formatEur(eurMax * 100)} +
+
+ + {/* Trade Summary */} +
+ {direction === "buy" ? ( +

+ You buy{" "} + + + + , you sell {formatEur(eurAmount)} +

+ ) : ( +

+ You buy {formatEur(eurAmount)}, you sell{" "} + + + +

+ )} +
+ + {/* Continue Button */} + +
+ ); +} diff --git a/frontend/app/exchange/components/PriceDisplay.tsx b/frontend/app/exchange/components/PriceDisplay.tsx new file mode 100644 index 0000000..e9e03a5 --- /dev/null +++ b/frontend/app/exchange/components/PriceDisplay.tsx @@ -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 = { + 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 ( +
+ {isLoading && !priceData ? ( +
Loading price...
+ ) : error && !priceData?.price ? ( +
{error}
+ ) : ( + <> +
+ Market: + {formatPrice(marketPrice)} + + Our price: + {formatPrice(agreedPrice)} + + {direction === "buy" ? "+" : "-"} + {premiumPercent}% + +
+ {lastUpdate && ( +
+ Updated {lastUpdate.toLocaleTimeString()} + {isPriceStale && (stale)} +
+ )} + + )} +
+ ); +} diff --git a/frontend/app/exchange/components/StepIndicator.tsx b/frontend/app/exchange/components/StepIndicator.tsx new file mode 100644 index 0000000..d082990 --- /dev/null +++ b/frontend/app/exchange/components/StepIndicator.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { CSSProperties } from "react"; + +type WizardStep = "details" | "booking" | "confirmation"; + +interface StepIndicatorProps { + currentStep: WizardStep; +} + +const styles: Record = { + 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 ( +
+
+ 1 + Exchange Details +
+
+
+ 2 + Book Appointment +
+
+
+ 3 + Confirm +
+
+ ); +} diff --git a/frontend/app/exchange/hooks/useAvailableSlots.ts b/frontend/app/exchange/hooks/useAvailableSlots.ts new file mode 100644 index 0000000..c1ba1ba --- /dev/null +++ b/frontend/app/exchange/hooks/useAvailableSlots.ts @@ -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; + /** 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; +} + +/** + * 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([]); + const [datesWithAvailability, setDatesWithAvailability] = useState>(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(`/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(); + + const promises = dates.map(async (date) => { + try { + const dateStr = formatDate(date); + const data = await api.get(`/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, + }; +} diff --git a/frontend/app/exchange/hooks/useExchangePrice.ts b/frontend/app/exchange/hooks/useExchangePrice.ts new file mode 100644 index 0000000..ae8661b --- /dev/null +++ b/frontend/app/exchange/hooks/useExchangePrice.ts @@ -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; +} + +/** + * 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(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdate, setLastUpdate] = useState(null); + + const fetchPrice = useCallback(async () => { + if (!enabled) return; + + setIsLoading(true); + setError(null); + + try { + const data = await api.get("/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, + }; +} diff --git a/frontend/app/exchange/page.tsx b/frontend/app/exchange/page.tsx index 482f524..c083bb7 100644 --- a/frontend/app/exchange/page.tsx +++ b/frontend/app/exchange/page.tsx @@ -1,22 +1,26 @@ "use client"; -import { useEffect, useState, useCallback, useMemo, ChangeEvent, CSSProperties } from "react"; +import { useEffect, useState, useCallback, useMemo } from "react"; import { useRouter } from "next/navigation"; import { Permission } from "../auth-context"; -import { api, ApiError } from "../api"; +import { api } from "../api"; +import { extractApiErrorMessage } from "../utils/error-handling"; 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 { formatDate, formatTime, getDateRange } from "../utils/date"; -import { formatEur } from "../utils/exchange"; -import { layoutStyles, typographyStyles, bannerStyles, buttonStyles } from "../styles/shared"; +import { formatDate, getDateRange } from "../utils/date"; +import { layoutStyles, typographyStyles, bannerStyles } from "../styles/shared"; import constants from "../../../shared/constants.json"; +import { useExchangePrice } from "./hooks/useExchangePrice"; +import { useAvailableSlots } from "./hooks/useAvailableSlots"; +import { PriceDisplay } from "./components/PriceDisplay"; +import { StepIndicator } from "./components/StepIndicator"; +import { ExchangeDetailsStep } from "./components/ExchangeDetailsStep"; +import { BookingStep } from "./components/BookingStep"; +import { ConfirmationStep } from "./components/ConfirmationStep"; -type ExchangePriceResponse = components["schemas"]["ExchangePriceResponse"]; type ExchangeResponse = components["schemas"]["ExchangeResponse"]; -type BookableSlot = components["schemas"]["BookableSlot"]; -type AvailableSlotsResponse = components["schemas"]["AvailableSlotsResponse"]; // Constants from shared config const { @@ -29,12 +33,27 @@ type Direction = "buy" | "sell"; type BitcoinTransferMethod = "onchain" | "lightning"; type WizardStep = "details" | "booking" | "confirmation"; -/** - * Format price for display - */ -function formatPrice(price: number): string { - return `€${price.toLocaleString("de-DE", { maximumFractionDigits: 0 })}`; -} +const styles = { + content: { + flex: 1, + padding: "2rem", + maxWidth: "900px", + margin: "0 auto", + width: "100%", + }, + 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", + }, +} as const; export default function ExchangePage() { const router = useRouter(); @@ -46,12 +65,6 @@ export default function ExchangePage() { // Wizard state const [wizardStep, setWizardStep] = useState("details"); - // Price and config state - const [priceData, setPriceData] = useState(null); - const [isPriceLoading, setIsPriceLoading] = useState(true); - const [priceError, setPriceError] = useState(null); - const [lastPriceUpdate, setLastPriceUpdate] = useState(null); - // Trade form state const [direction, setDirection] = useState("buy"); const [bitcoinTransferMethod, setBitcoinTransferMethod] = @@ -60,11 +73,9 @@ export default function ExchangePage() { // Date/slot selection state const [selectedDate, setSelectedDate] = useState(null); - const [availableSlots, setAvailableSlots] = useState([]); - const [selectedSlot, setSelectedSlot] = useState(null); - const [isLoadingSlots, setIsLoadingSlots] = useState(false); - const [datesWithAvailability, setDatesWithAvailability] = useState>(new Set()); - const [isLoadingAvailability, setIsLoadingAvailability] = useState(true); + const [selectedSlot, setSelectedSlot] = useState( + null + ); // User trades state (for same-day booking check) const [userTrades, setUserTrades] = useState([]); @@ -79,6 +90,28 @@ export default function ExchangePage() { // Compute dates const dates = useMemo(() => getDateRange(MIN_ADVANCE_DAYS, MAX_ADVANCE_DAYS), []); + // Use custom hooks for price and slots + const { + priceData, + isLoading: isPriceLoading, + error: priceError, + lastUpdate: lastPriceUpdate, + } = useExchangePrice({ + enabled: !!user && isAuthorized, + }); + + const { + availableSlots, + datesWithAvailability, + isLoadingSlots, + isLoadingAvailability, + fetchSlots, + } = useAvailableSlots({ + enabled: !!user && isAuthorized, + dates, + wizardStep, + }); + // Config from API const config = priceData?.config; const eurMin = config?.eur_min ?? 100; @@ -120,57 +153,12 @@ export default function ExchangePage() { } }, [isLightningDisabled, bitcoinTransferMethod]); - // Fetch price data - const fetchPrice = useCallback(async () => { - setIsPriceLoading(true); - setPriceError(null); - - try { - const data = await api.get("/api/exchange/price"); - setPriceData(data); - setLastPriceUpdate(new Date()); - - if (data.error) { - setPriceError(data.error); - } - if (data.price?.is_stale) { - setPriceError("Price is stale. Trade booking may be blocked."); - } - } catch (err) { - console.error("Failed to fetch price:", err); - setPriceError("Failed to load price data"); - } finally { - setIsPriceLoading(false); - } - }, []); - - // Auto-refresh price every 60 seconds + // Fetch slots when date is selected useEffect(() => { - if (!user || !isAuthorized) return; - - fetchPrice(); - const interval = setInterval(fetchPrice, 60000); - return () => clearInterval(interval); - }, [user, isAuthorized, fetchPrice]); - - // Fetch slots for a date - const fetchSlots = useCallback(async (date: Date) => { - setIsLoadingSlots(true); - setError(null); - setAvailableSlots([]); - setSelectedSlot(null); - - try { - const dateStr = formatDate(date); - const data = await api.get(`/api/exchange/slots?date=${dateStr}`); - setAvailableSlots(data.slots); - } catch (err) { - console.error("Failed to fetch slots:", err); - setError("Failed to load available slots"); - } finally { - setIsLoadingSlots(false); + if (selectedDate && user && isAuthorized) { + fetchSlots(selectedDate); } - }, []); + }, [selectedDate, user, isAuthorized, fetchSlots]); // Fetch user trades when entering booking step useEffect(() => { @@ -189,41 +177,6 @@ export default function ExchangePage() { fetchUserTrades(); }, [user, isAuthorized, wizardStep]); - // Fetch availability for all dates when entering booking or confirmation step - useEffect(() => { - if (!user || !isAuthorized || (wizardStep !== "booking" && wizardStep !== "confirmation")) - return; - - const fetchAllAvailability = async () => { - setIsLoadingAvailability(true); - const availabilitySet = new Set(); - - const promises = dates.map(async (date) => { - try { - const dateStr = formatDate(date); - const data = await api.get(`/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(); - }, [user, isAuthorized, dates, wizardStep]); - - useEffect(() => { - if (selectedDate && user && isAuthorized) { - fetchSlots(selectedDate); - } - }, [selectedDate, user, isAuthorized, fetchSlots]); - // Check if a date has an existing trade (only consider booked trades, not cancelled ones) const getExistingTradeOnDate = useCallback( (date: Date): ExchangeResponse | null => { @@ -250,7 +203,6 @@ export default function ExchangePage() { setExistingTradeOnSelectedDate(existingTrade); setSelectedDate(null); setSelectedSlot(null); - setAvailableSlots([]); setError(null); } else { setExistingTradeOnSelectedDate(null); @@ -258,7 +210,7 @@ export default function ExchangePage() { } }; - const handleSlotSelect = (slot: BookableSlot) => { + const handleSlotSelect = (slot: components["schemas"]["BookableSlot"]) => { setSelectedSlot(slot); setError(null); setWizardStep("confirmation"); @@ -282,31 +234,6 @@ export default function ExchangePage() { setError(null); }; - 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; - - setEurAmount(clamped); - }; - - const handleAmountInputChange = (e: ChangeEvent) => { - const inputValue = e.target.value.replace(/[^0-9]/g, ""); - if (inputValue === "") { - setEurAmount(eurMin * 100); - return; - } - const eurValue = parseInt(inputValue, 10); - handleAmountChange(eurValue * 100); - }; - const handleBook = async () => { if (!selectedSlot) return; @@ -325,18 +252,7 @@ export default function ExchangePage() { // Redirect to trades page after successful booking router.push("/trades"); } catch (err) { - let errorMessage = "Failed to book trade"; - if (err instanceof ApiError) { - // Extract detail from API error response - if (err.data && typeof err.data === "object") { - const data = err.data as { detail?: string }; - errorMessage = data.detail || err.message; - } else { - errorMessage = err.message; - } - } else if (err instanceof Error) { - errorMessage = err.message; - } + const errorMessage = extractApiErrorMessage(err, "Failed to book trade"); setError(errorMessage); // Check if it's a "same day" error and extract trade public_id (UUID) @@ -352,11 +268,7 @@ export default function ExchangePage() { }; if (isLoading) { - return ( -
-
Loading...
-
- ); + return ; } if (!isAuthorized) { @@ -386,940 +298,76 @@ export default function ExchangePage() { )} {/* Price Display */} -
- {isPriceLoading && !priceData ? ( -
Loading price...
- ) : priceError && !priceData?.price ? ( -
{priceError}
- ) : ( - <> -
- Market: - {formatPrice(marketPrice)} - - Our price: - {formatPrice(agreedPrice)} - - {direction === "buy" ? "+" : "-"} - {premiumPercent}% - -
- {lastPriceUpdate && ( -
- Updated {lastPriceUpdate.toLocaleTimeString()} - {isPriceStale && (stale)} -
- )} - - )} -
+ {/* Step Indicator */} -
-
- 1 - Exchange Details -
-
-
- 2 - Book Appointment -
-
-
- 3 - Confirm -
-
+ {/* Step 1: Exchange Details */} {wizardStep === "details" && ( -
- {/* Direction Selector */} -
- - -
- - {/* Payment Method Selector */} -
-
- Payment Method * -
-
- - -
- {isLightningDisabled && ( -
- Lightning payments are only available for amounts up to €{LIGHTNING_MAX_EUR} -
- )} -
- - {/* Amount Section */} -
-
- Amount (EUR) -
- - -
-
- setEurAmount(Number(e.target.value))} - style={styles.slider} - /> -
- {formatEur(eurMin * 100)} - {formatEur(eurMax * 100)} -
-
- - {/* Trade Summary */} -
- {direction === "buy" ? ( -

- You buy{" "} - - - - , you sell {formatEur(eurAmount)} -

- ) : ( -

- You buy {formatEur(eurAmount)}, you sell{" "} - - - -

- )} -
- - {/* Continue Button */} - -
+ )} {/* Step 2: Booking */} {wizardStep === "booking" && ( - <> - {/* Trade Summary Card */} -
-
- Your Exchange - -
-
- - {direction === "buy" ? "Buy" : "Sell"} BTC - - - {formatEur(eurAmount)} - - - - - - - {direction === "buy" ? "Receive via " : "Send via "} - {bitcoinTransferMethod === "onchain" ? "Onchain" : "Lightning"} - -
-
- - {/* Date Selection */} -
-

Select a Date

-
- {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) !== null; - - return ( - - ); - })} -
-
- - {/* Warning for existing trade on selected date */} - {existingTradeOnSelectedDate && ( -
-
- You already have a trade booked on this day. You can only book one trade per day. -
- -
- )} - - {/* Available Slots */} - {selectedDate && !existingTradeOnSelectedDate && ( -
-

- Available Slots for{" "} - {selectedDate.toLocaleDateString("en-US", { - weekday: "long", - month: "long", - day: "numeric", - })} -

- - {isLoadingSlots ? ( -
Loading slots...
- ) : availableSlots.length === 0 ? ( -
No available slots for this date
- ) : ( -
- {availableSlots.map((slot) => { - const isSelected = selectedSlot?.start_time === slot.start_time; - return ( - - ); - })} -
- )} -
- )} - - )} - - {/* Step 2: Booking (Compressed when step 3 is active) */} - {wizardStep === "confirmation" && ( -
-
- Appointment - -
-
- - {selectedDate?.toLocaleDateString("en-US", { - weekday: "short", - month: "short", - day: "numeric", - })} - - - - {selectedSlot && formatTime(selectedSlot.start_time)} -{" "} - {selectedSlot && formatTime(selectedSlot.end_time)} - -
-
+ )} {/* Step 3: Confirmation */} {wizardStep === "confirmation" && selectedSlot && ( -
-

Confirm Trade

-
-
- Time: - - {formatTime(selectedSlot.start_time)} - {formatTime(selectedSlot.end_time)} - -
-
- Direction: - - {direction === "buy" ? "Buy BTC" : "Sell BTC"} - -
-
- EUR: - {formatEur(eurAmount)} -
-
- BTC: - - - -
-
- Rate: - {formatPrice(agreedPrice)}/BTC -
-
- Payment: - - {direction === "buy" ? "Receive via " : "Send via "} - {bitcoinTransferMethod === "onchain" ? "Onchain" : "Lightning"} - -
-
- -
- - -
-
+ )}
); } - -// Page-specific styles -const styles: Record = { - content: { - flex: 1, - padding: "2rem", - maxWidth: "900px", - margin: "0 auto", - width: "100%", - }, - 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, - }, - 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)", - }, - 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", - }, - 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", - }, - 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", - }, - 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", - }, - 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)", - }, - 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)", - }, - 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", - }, - 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, - }, - 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", - }, - 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, - }, - 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", - }, -}; diff --git a/frontend/app/hooks/useDebouncedValidation.ts b/frontend/app/hooks/useDebouncedValidation.ts new file mode 100644 index 0000000..7477a75 --- /dev/null +++ b/frontend/app/hooks/useDebouncedValidation.ts @@ -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( + formData: T, + validator: (data: T) => Record, + delay: number = 500 +): { + errors: Record; + setErrors: React.Dispatch>>; + validate: (data?: T) => void; +} { + const [errors, setErrors] = useState>({}); + const validationTimeoutRef = useRef(null); + const formDataRef = useRef(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 }; +} diff --git a/frontend/app/hooks/useRequireAuth.ts b/frontend/app/hooks/useRequireAuth.ts index d5eb944..a1566b8 100644 --- a/frontend/app/hooks/useRequireAuth.ts +++ b/frontend/app/hooks/useRequireAuth.ts @@ -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, diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index 8d3c18b..7720d0c 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -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(null); - const [errors, setErrors] = useState({}); const [isLoadingProfile, setIsLoadingProfile] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null); - const validationTimeoutRef = useRef(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) => { let value = e.target.value; @@ -121,19 +111,11 @@ export default function ProfilePage() { } } - setFormData((prev) => ({ ...prev, [field]: value })); + const newFormData = { ...formData, [field]: value }; + setFormData(newFormData); - // Clear any pending validation timeout - 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); + // 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 ( -
-
Loading...
-
- ); + return ; } if (!user || !isAuthorized) { @@ -194,14 +173,7 @@ export default function ProfilePage() {
{/* Toast notification */} {toast && ( -
- {toast.message} -
+ setToast(null)} /> )}
diff --git a/frontend/app/styles/auth-form.ts b/frontend/app/styles/auth-form.ts index 4ef086b..efe6701 100644 --- a/frontend/app/styles/auth-form.ts +++ b/frontend/app/styles/auth-form.ts @@ -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 = { 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 = { 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: { diff --git a/frontend/app/trades/page.tsx b/frontend/app/trades/page.tsx index 0eff03f..f9e7a13 100644 --- a/frontend/app/trades/page.tsx +++ b/frontend/app/trades/page.tsx @@ -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 ( -
-
Loading...
-
- ); + return ; } if (!isAuthorized) { diff --git a/frontend/app/utils/error-handling.ts b/frontend/app/utils/error-handling.ts new file mode 100644 index 0000000..101e0cc --- /dev/null +++ b/frontend/app/utils/error-handling.ts @@ -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 } } | undefined { + if (err instanceof ApiError && err.status === 422) { + if (err.data && typeof err.data === "object") { + return err.data as { detail?: { field_errors?: Record } }; + } + } + return undefined; +}