diff --git a/REFACTOR_PLAN.md b/REFACTOR_PLAN.md deleted file mode 100644 index 76cb3ba..0000000 --- a/REFACTOR_PLAN.md +++ /dev/null @@ -1,244 +0,0 @@ -# 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 4435fbf..7e8a641 100644 --- a/backend/exceptions.py +++ b/backend/exceptions.py @@ -51,16 +51,6 @@ 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).""" @@ -69,16 +59,3 @@ 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 5ebd8fa..8f2ad4a 100644 --- a/backend/mappers.py +++ b/backend/mappers.py @@ -1,12 +1,11 @@ """Response mappers for converting models to API response schemas.""" -from models import Exchange, Invite, PriceHistory +from models import Exchange, Invite from schemas import ( AdminExchangeResponse, ExchangeResponse, ExchangeUserContact, InviteResponse, - PriceHistoryResponse, ) @@ -90,19 +89,3 @@ 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 805dee7..aff0836 100644 --- a/backend/repositories/__init__.py +++ b/backend/repositories/__init__.py @@ -1,17 +1,6 @@ """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__ = [ - "AvailabilityRepository", - "ExchangeRepository", - "InviteRepository", - "PriceRepository", - "RoleRepository", - "UserRepository", -] +__all__ = ["PriceRepository", "UserRepository"] diff --git a/backend/repositories/availability.py b/backend/repositories/availability.py deleted file mode 100644 index b530c0e..0000000 --- a/backend/repositories/availability.py +++ /dev/null @@ -1,70 +0,0 @@ -"""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 deleted file mode 100644 index 81160c9..0000000 --- a/backend/repositories/exchange.py +++ /dev/null @@ -1,192 +0,0 @@ -"""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 deleted file mode 100644 index 4f150fc..0000000 --- a/backend/repositories/invite.py +++ /dev/null @@ -1,128 +0,0 @@ -"""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 bf6a566..b8322da 100644 --- a/backend/repositories/price.py +++ b/backend/repositories/price.py @@ -1,7 +1,5 @@ """Price repository for database queries.""" -from datetime import datetime - from sqlalchemy import desc, select from sqlalchemy.ext.asyncio import AsyncSession @@ -27,47 +25,3 @@ 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 deleted file mode 100644 index c56c734..0000000 --- a/backend/repositories/role.py +++ /dev/null @@ -1,18 +0,0 @@ -"""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 f930427..d4c12ce 100644 --- a/backend/repositories/user.py +++ b/backend/repositories/user.py @@ -21,44 +21,3 @@ 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 30c44b7..0cc725b 100644 --- a/backend/routes/audit.py +++ b/backend/routes/audit.py @@ -1,22 +1,36 @@ """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 mappers import PriceHistoryMapper -from models import Permission, User +from models import Permission, PriceHistory, User +from price_fetcher import PAIR_BTC_EUR, SOURCE_BITFINEX, fetch_btc_eur_price 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( @@ -24,10 +38,15 @@ async def get_price_history( _current_user: User = Depends(require_permission(Permission.VIEW_AUDIT)), ) -> list[PriceHistoryResponse]: """Get the 20 most recent price history records.""" - service = PriceService(db) - records = await service.get_recent_prices() + query = ( + select(PriceHistory) + .order_by(desc(PriceHistory.timestamp)) + .limit(PRICE_HISTORY_LIMIT) + ) + result = await db.execute(query) + records = result.scalars().all() - return [PriceHistoryMapper.to_response(record) for record in records] + return [_to_price_history_response(record) for record in records] @router.post("/price-history/fetch", response_model=PriceHistoryResponse) @@ -36,7 +55,28 @@ async def fetch_price_now( _current_user: User = Depends(require_permission(Permission.FETCH_PRICE)), ) -> PriceHistoryResponse: """Manually trigger a price fetch from Bitfinex.""" - service = PriceService(db) - record = await service.fetch_and_store_price() + price, timestamp = await fetch_btc_eur_price() - return PriceHistoryMapper.to_response(record) + 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) diff --git a/backend/routes/auth.py b/backend/routes/auth.py index f228ab1..604b50d 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -1,19 +1,26 @@ """Authentication routes for register, login, logout, and current user.""" -from fastapi import APIRouter, Depends, Response +from datetime import UTC, datetime + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from sqlalchemy import select 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 models import User +from invite_utils import normalize_identifier +from models import ROLE_REGULAR, Invite, InviteStatus, Role, User from schemas import RegisterWithInvite, UserLogin, UserResponse -from services.auth import AuthService router = APIRouter(prefix="/api/auth", tags=["auth"]) @@ -30,6 +37,12 @@ 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, @@ -37,13 +50,51 @@ async def register( db: AsyncSession = Depends(get_db), ) -> UserResponse: """Register a new user using an invite code.""" - service = AuthService(db) - user, access_token = await service.register_user( + # 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( email=user_data.email, - password=user_data.password, - invite_identifier=user_data.invite_identifier, + hashed_password=get_password_hash(user_data.password), + godfather_id=invite.godfather_id, ) + # 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) @@ -55,11 +106,14 @@ async def login( db: AsyncSession = Depends(get_db), ) -> UserResponse: """Authenticate a user and return their info with an auth cookie.""" - service = AuthService(db) - user, access_token = await service.login_user( - email=user_data.email, password=user_data.password - ) + 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", + ) + 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 84b8c97..cd21d56 100644 --- a/backend/routes/availability.py +++ b/backend/routes/availability.py @@ -2,19 +2,21 @@ from datetime import date -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import and_, delete, select from sqlalchemy.ext.asyncio import AsyncSession from auth import require_permission from database import get_db -from models import Permission, User +from date_validation import validate_date_in_range +from models import Availability, Permission, User from schemas import ( AvailabilityDay, AvailabilityResponse, CopyAvailabilityRequest, SetAvailabilityRequest, + TimeSlot, ) -from services.availability import AvailabilityService router = APIRouter(prefix="/api/admin/availability", tags=["availability"]) @@ -27,8 +29,38 @@ async def get_availability( _current_user: User = Depends(require_permission(Permission.MANAGE_AVAILABILITY)), ) -> AvailabilityResponse: """Get availability slots for a date range.""" - service = AvailabilityService(db) - return await service.get_availability_for_range(from_date, to_date) + 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) @router.put("", response_model=AvailabilityDay) @@ -38,8 +70,44 @@ async def set_availability( _current_user: User = Depends(require_permission(Permission.MANAGE_AVAILABILITY)), ) -> AvailabilityDay: """Set availability for a specific date. Replaces any existing availability.""" - service = AvailabilityService(db) - return await service.set_availability_for_date(request.date, request.slots) + 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) @router.post("/copy", response_model=AvailabilityResponse) @@ -49,5 +117,62 @@ async def copy_availability( _current_user: User = Depends(require_permission(Permission.MANAGE_AVAILABILITY)), ) -> AvailabilityResponse: """Copy availability from one day to multiple target days.""" - service = AvailabilityService(db) - return await service.copy_availability(request.source_date, request.target_dates) + # 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) diff --git a/backend/routes/exchange.py b/backend/routes/exchange.py index 95e20ea..96879af 100644 --- a/backend/routes/exchange.py +++ b/backend/routes/exchange.py @@ -1,16 +1,22 @@ """Exchange routes for Bitcoin trading.""" import uuid -from datetime import date +from datetime import UTC, date, datetime, time, timedelta 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, @@ -18,11 +24,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, @@ -36,8 +42,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"]) @@ -144,6 +150,30 @@ 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"), @@ -157,8 +187,42 @@ async def get_available_slots( - Fall within admin-defined availability windows - Are not already booked by another user """ - service = ExchangeService(db) - return await service.get_available_slots(date_param) + 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) # ============================================================================= @@ -183,16 +247,21 @@ async def create_exchange( - EUR amount is within configured limits """ # Validate direction - direction: TradeDirection = validate_enum( - TradeDirection, request.direction, "direction" - ) + try: + direction = TradeDirection(request.direction) + except ValueError: + raise BadRequestError( + f"Invalid direction: {request.direction}. Must be 'buy' or 'sell'." + ) from None # Validate bitcoin transfer method - bitcoin_transfer_method: BitcoinTransferMethod = validate_enum( - BitcoinTransferMethod, - request.bitcoin_transfer_method, - "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 # Use service to create exchange (handles all validation) service = ExchangeService(db) @@ -220,8 +289,12 @@ 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).""" - exchange_repo = ExchangeRepository(db) - exchanges = await exchange_repo.get_by_user_id(current_user.id, order_by_desc=True) + result = await db.execute( + select(Exchange) + .where(Exchange.user_id == current_user.id) + .order_by(Exchange.slot_start.desc()) + ) + exchanges = result.scalars().all() return [ExchangeMapper.to_response(ex, current_user.email) for ex in exchanges] @@ -275,8 +348,19 @@ 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.""" - exchange_repo = ExchangeRepository(db) - exchanges = await exchange_repo.get_upcoming_booked() + 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() return [ExchangeMapper.to_admin_response(ex) for ex in exchanges] @@ -299,20 +383,46 @@ async def get_past_trades( - user_search: Search by user email (partial match) """ - # Apply status filter - status_enum: ExchangeStatus | None = None - if status: - status_enum = validate_enum(ExchangeStatus, status, "status") + now = datetime.now(UTC) - # 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, + # 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 + 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 + + # 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() + return [ExchangeMapper.to_admin_response(ex) for ex in exchanges] @@ -377,10 +487,6 @@ 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 812ab3c..7e50472 100644 --- a/backend/routes/invites.py +++ b/backend/routes/invites.py @@ -1,13 +1,23 @@ """Invite routes for public check, user invites, and admin management.""" -from fastapi import APIRouter, Depends, Query -from sqlalchemy import select +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 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 Permission, User +from models import Invite, InviteStatus, Permission, User +from pagination import calculate_offset, create_paginated_response from schemas import ( AdminUserResponse, InviteCheckResponse, @@ -16,11 +26,12 @@ 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( @@ -28,8 +39,20 @@ async def check_invite( db: AsyncSession = Depends(get_db), ) -> InviteCheckResponse: """Check if an invite is valid and can be used for signup.""" - service = InviteService(db) - return await service.check_invite_validity(identifier) + 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) @router.get("", response_model=list[UserInviteResponse]) @@ -38,9 +61,14 @@ 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.""" - service = InviteService(db) - invites = await service.get_user_invites(current_user.id) + result = await db.execute( + select(Invite) + .where(Invite.godfather_id == current_user.id) + .order_by(desc(Invite.created_at)) + ) + invites = result.scalars().all() + # Use preloaded used_by relationship (selectin loading) return [ UserInviteResponse( id=invite.id, @@ -60,8 +88,6 @@ 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] @@ -74,8 +100,39 @@ async def create_invite( _current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)), ) -> InviteResponse: """Create a new invite for a specified godfather user.""" - service = InviteService(db) - invite = await service.create_invite(data.godfather_id) + # 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", + ) return InviteMapper.to_response(invite) @@ -91,13 +148,41 @@ async def list_all_invites( _current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)), ) -> PaginatedInviteRecords: """List all invites with optional filtering and pagination.""" - service = InviteService(db) - return await service.list_invites( - page=page, - per_page=per_page, - status_filter=status_filter, - godfather_id=godfather_id, - ) + # 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) @admin_router.post("/invites/{invite_id}/revoke", response_model=InviteResponse) @@ -107,8 +192,23 @@ async def revoke_invite( _current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)), ) -> InviteResponse: """Revoke an invite. Only READY invites can be revoked.""" - service = InviteService(db) - invite = await service.revoke_invite(invite_id) + 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) + return InviteMapper.to_response(invite) diff --git a/backend/routes/profile.py b/backend/routes/profile.py index 5c474cf..bd294cb 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -1,25 +1,41 @@ """Profile routes for user contact details.""" -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select 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 services.profile import ProfileService +from validation import validate_profile_fields 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).""" - service = ProfileService(db) - return await service.get_profile(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, + ) @router.put("", response_model=ProfileResponse) @@ -29,5 +45,36 @@ async def update_profile( current_user: User = Depends(require_permission(Permission.MANAGE_OWN_PROFILE)), ) -> ProfileResponse: """Update the current user's profile (contact details).""" - service = ProfileService(db) - return await service.update_profile(current_user, data) + # 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, + ) diff --git a/backend/services/auth.py b/backend/services/auth.py deleted file mode 100644 index 9bb64a3..0000000 --- a/backend/services/auth.py +++ /dev/null @@ -1,115 +0,0 @@ -"""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 deleted file mode 100644 index 438b5c5..0000000 --- a/backend/services/availability.py +++ /dev/null @@ -1,197 +0,0 @@ -"""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 05feaa9..cf1f641 100644 --- a/backend/services/exchange.py +++ b/backend/services/exchange.py @@ -1,8 +1,9 @@ """Exchange service for business logic related to Bitcoin trading.""" import uuid -from datetime import UTC, date, datetime, timedelta +from datetime import UTC, date, datetime, time, timedelta +from sqlalchemy import and_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -22,10 +23,7 @@ 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, @@ -46,8 +44,6 @@ 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, @@ -111,21 +107,20 @@ 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() - 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 + 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() if not matching_availability: slot_str = slot_start.strftime("%Y-%m-%d %H:%M") @@ -176,19 +171,29 @@ class ExchangeService: self, user: User, slot_date: date ) -> Exchange | None: """Check if user already has a trade on this date.""" - 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, + 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, + ) ) - return exchanges[0] if exchanges else None + result = await self.db.execute(existing_trade_query) + return result.scalar_one_or_none() async def check_slot_already_booked(self, slot_start: datetime) -> Exchange | None: """Check if slot is already booked (only consider BOOKED status).""" - return await self.exchange_repo.get_by_slot_start( - slot_start, status=ExchangeStatus.BOOKED + slot_booked_query = select(Exchange).where( + and_( + Exchange.slot_start == slot_start, + Exchange.status == ExchangeStatus.BOOKED, + ) ) + result = await self.db.execute(slot_booked_query) + return result.scalar_one_or_none() async def create_exchange( self, @@ -267,8 +272,11 @@ class ExchangeService: status=ExchangeStatus.BOOKED, ) + self.db.add(exchange) + try: - return await self.exchange_repo.create(exchange) + await self.db.commit() + await self.db.refresh(exchange) except IntegrityError as e: await self.db.rollback() # This should rarely happen now since we check explicitly above, @@ -277,6 +285,8 @@ 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: @@ -287,7 +297,9 @@ class ExchangeService: NotFoundError: If exchange not found or user doesn't own it (for security, returns 404) """ - exchange = await self.exchange_repo.get_by_public_id(public_id) + query = select(Exchange).where(Exchange.public_id == public_id) + result = await self.db.execute(query) + exchange = result.scalar_one_or_none() if not exchange: raise NotFoundError("Trade") @@ -326,7 +338,10 @@ class ExchangeService: ) exchange.cancelled_at = datetime.now(UTC) - return await self.exchange_repo.update(exchange) + await self.db.commit() + await self.db.refresh(exchange) + + return exchange async def complete_exchange(self, exchange: Exchange) -> Exchange: """ @@ -346,7 +361,10 @@ class ExchangeService: exchange.status = ExchangeStatus.COMPLETED exchange.completed_at = datetime.now(UTC) - return await self.exchange_repo.update(exchange) + await self.db.commit() + await self.db.refresh(exchange) + + return exchange async def mark_no_show(self, exchange: Exchange) -> Exchange: """ @@ -368,74 +386,7 @@ class ExchangeService: exchange.status = ExchangeStatus.NO_SHOW exchange.completed_at = datetime.now(UTC) - return await self.exchange_repo.update(exchange) + await self.db.commit() + await self.db.refresh(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) + return exchange diff --git a/backend/services/invite.py b/backend/services/invite.py deleted file mode 100644 index 9e71782..0000000 --- a/backend/services/invite.py +++ /dev/null @@ -1,193 +0,0 @@ -"""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 deleted file mode 100644 index 4d1864e..0000000 --- a/backend/services/price.py +++ /dev/null @@ -1,67 +0,0 @@ -"""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 deleted file mode 100644 index 447022f..0000000 --- a/backend/services/profile.py +++ /dev/null @@ -1,81 +0,0 @@ -"""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 e63fa5b..20042ea 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( - "services.invite.generate_invite_identifier", side_effect=mock_generator + "routes.invites.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 39861bb..4de7b6f 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("services.price.fetch_btc_eur_price") as mock_fetch: + with patch("routes.audit.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 deleted file mode 100644 index a245cc5..0000000 --- a/backend/utils/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Utility modules for common functionality.""" diff --git a/backend/utils/date_queries.py b/backend/utils/date_queries.py deleted file mode 100644 index 71f5051..0000000 --- a/backend/utils/date_queries.py +++ /dev/null @@ -1,27 +0,0 @@ -"""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 deleted file mode 100644 index 2b4b78b..0000000 --- a/backend/utils/enum_validation.py +++ /dev/null @@ -1,32 +0,0 @@ -"""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 85b79f8..7b23511 100644 --- a/frontend/app/auth-context.tsx +++ b/frontend/app/auth-context.tsx @@ -2,9 +2,8 @@ import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react"; -import { api } from "./api"; +import { api, ApiError } 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"]; @@ -68,7 +67,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { const userData = await api.post("/api/auth/login", { email, password }); setUser(userData); } catch (err) { - throw new Error(extractApiErrorMessage(err, "Login failed")); + if (err instanceof ApiError) { + const data = err.data as { detail?: string }; + throw new Error(data?.detail || "Login failed"); + } + throw err; } }; @@ -81,7 +84,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { }); setUser(userData); } catch (err) { - throw new Error(extractApiErrorMessage(err, "Registration failed")); + if (err instanceof ApiError) { + const data = err.data as { detail?: string }; + throw new Error(data?.detail || "Registration failed"); + } + throw err; } }; diff --git a/frontend/app/components/LoadingState.tsx b/frontend/app/components/LoadingState.tsx deleted file mode 100644 index ff92367..0000000 --- a/frontend/app/components/LoadingState.tsx +++ /dev/null @@ -1,20 +0,0 @@ -"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 deleted file mode 100644 index 5e9cbfb..0000000 --- a/frontend/app/components/Toast.tsx +++ /dev/null @@ -1,40 +0,0 @@ -"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 deleted file mode 100644 index e84f076..0000000 --- a/frontend/app/exchange/components/BookingStep.tsx +++ /dev/null @@ -1,347 +0,0 @@ -"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 deleted file mode 100644 index 1cb0197..0000000 --- a/frontend/app/exchange/components/ConfirmationStep.tsx +++ /dev/null @@ -1,252 +0,0 @@ -"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 deleted file mode 100644 index 3219baf..0000000 --- a/frontend/app/exchange/components/ExchangeDetailsStep.tsx +++ /dev/null @@ -1,377 +0,0 @@ -"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 deleted file mode 100644 index e9e03a5..0000000 --- a/frontend/app/exchange/components/PriceDisplay.tsx +++ /dev/null @@ -1,130 +0,0 @@ -"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 deleted file mode 100644 index d082990..0000000 --- a/frontend/app/exchange/components/StepIndicator.tsx +++ /dev/null @@ -1,98 +0,0 @@ -"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 deleted file mode 100644 index c1ba1ba..0000000 --- a/frontend/app/exchange/hooks/useAvailableSlots.ts +++ /dev/null @@ -1,97 +0,0 @@ -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 deleted file mode 100644 index ae8661b..0000000 --- a/frontend/app/exchange/hooks/useExchangePrice.ts +++ /dev/null @@ -1,73 +0,0 @@ -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 c083bb7..482f524 100644 --- a/frontend/app/exchange/page.tsx +++ b/frontend/app/exchange/page.tsx @@ -1,26 +1,22 @@ "use client"; -import { useEffect, useState, useCallback, useMemo } from "react"; +import { useEffect, useState, useCallback, useMemo, ChangeEvent, CSSProperties } from "react"; import { useRouter } from "next/navigation"; import { Permission } from "../auth-context"; -import { api } from "../api"; -import { extractApiErrorMessage } from "../utils/error-handling"; +import { api, ApiError } from "../api"; import { Header } from "../components/Header"; -import { LoadingState } from "../components/LoadingState"; +import { SatsDisplay } from "../components/SatsDisplay"; import { useRequireAuth } from "../hooks/useRequireAuth"; import { components } from "../generated/api"; -import { formatDate, getDateRange } from "../utils/date"; -import { layoutStyles, typographyStyles, bannerStyles } from "../styles/shared"; +import { formatDate, formatTime, getDateRange } from "../utils/date"; +import { formatEur } from "../utils/exchange"; +import { layoutStyles, typographyStyles, bannerStyles, buttonStyles } 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 { @@ -33,27 +29,12 @@ type Direction = "buy" | "sell"; type BitcoinTransferMethod = "onchain" | "lightning"; type WizardStep = "details" | "booking" | "confirmation"; -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; +/** + * Format price for display + */ +function formatPrice(price: number): string { + return `€${price.toLocaleString("de-DE", { maximumFractionDigits: 0 })}`; +} export default function ExchangePage() { const router = useRouter(); @@ -65,6 +46,12 @@ 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] = @@ -73,9 +60,11 @@ export default function ExchangePage() { // Date/slot selection state const [selectedDate, setSelectedDate] = useState(null); - const [selectedSlot, setSelectedSlot] = 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); // User trades state (for same-day booking check) const [userTrades, setUserTrades] = useState([]); @@ -90,28 +79,6 @@ 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; @@ -153,12 +120,57 @@ export default function ExchangePage() { } }, [isLightningDisabled, bitcoinTransferMethod]); - // Fetch slots when date is selected - useEffect(() => { - if (selectedDate && user && isAuthorized) { - fetchSlots(selectedDate); + // 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); } - }, [selectedDate, user, isAuthorized, fetchSlots]); + }, []); + + // Auto-refresh price every 60 seconds + 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); + } + }, []); // Fetch user trades when entering booking step useEffect(() => { @@ -177,6 +189,41 @@ 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 => { @@ -203,6 +250,7 @@ export default function ExchangePage() { setExistingTradeOnSelectedDate(existingTrade); setSelectedDate(null); setSelectedSlot(null); + setAvailableSlots([]); setError(null); } else { setExistingTradeOnSelectedDate(null); @@ -210,7 +258,7 @@ export default function ExchangePage() { } }; - const handleSlotSelect = (slot: components["schemas"]["BookableSlot"]) => { + const handleSlotSelect = (slot: BookableSlot) => { setSelectedSlot(slot); setError(null); setWizardStep("confirmation"); @@ -234,6 +282,31 @@ 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; @@ -252,7 +325,18 @@ export default function ExchangePage() { // Redirect to trades page after successful booking router.push("/trades"); } catch (err) { - const errorMessage = extractApiErrorMessage(err, "Failed to book trade"); + 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; + } setError(errorMessage); // Check if it's a "same day" error and extract trade public_id (UUID) @@ -268,7 +352,11 @@ export default function ExchangePage() { }; if (isLoading) { - return ; + return ( +
+
Loading...
+
+ ); } if (!isAuthorized) { @@ -298,76 +386,940 @@ 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 deleted file mode 100644 index 7477a75..0000000 --- a/frontend/app/hooks/useDebouncedValidation.ts +++ /dev/null @@ -1,54 +0,0 @@ -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 a1566b8..d5eb944 100644 --- a/frontend/app/hooks/useRequireAuth.ts +++ b/frontend/app/hooks/useRequireAuth.ts @@ -46,7 +46,6 @@ 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) @@ -56,11 +55,7 @@ export function useRequireAuth(options: UseRequireAuthOptions = {}): UseRequireA : "/login"); router.push(redirect); } - // 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]); + }, [isLoading, user, isAuthorized, router, fallbackRedirect, hasPermission]); return { user, diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index 7720d0c..8d3c18b 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -1,24 +1,21 @@ "use client"; -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useRef } from "react"; -import { api } from "../api"; -import { extractApiErrorMessage, extractFieldErrors } from "../utils/error-handling"; +import { api, ApiError } from "../api"; 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 { validateProfileFields } from "../utils/validation"; +import { FieldErrors, validateProfileFields } from "../utils/validation"; // Use generated type from OpenAPI schema type ProfileData = components["schemas"]["ProfileResponse"]; @@ -53,15 +50,11 @@ 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 { - errors, - setErrors, - validate: validateForm, - } = useDebouncedValidation(formData, validateProfileFields, 500); + const validationTimeoutRef = useRef(null); // Check if form has changes const hasChanges = useCallback(() => { @@ -100,6 +93,23 @@ 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; @@ -111,11 +121,19 @@ export default function ProfilePage() { } } - const newFormData = { ...formData, [field]: value }; - setFormData(newFormData); + setFormData((prev) => ({ ...prev, [field]: value })); - // Trigger debounced validation with the new data - validateForm(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); }; const handleSubmit = async (e: React.FormEvent) => { @@ -144,15 +162,14 @@ export default function ProfilePage() { setToast({ message: "Profile saved successfully!", type: "success" }); } catch (err) { console.error("Profile save error:", err); - const fieldErrors = extractFieldErrors(err); - if (fieldErrors?.detail?.field_errors) { - setErrors(fieldErrors.detail.field_errors); + 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); + } setToast({ message: "Please fix the errors below", type: "error" }); } else { - setToast({ - message: extractApiErrorMessage(err, "Network error. Please try again."), - type: "error", - }); + setToast({ message: "Network error. Please try again.", type: "error" }); } } finally { setIsSubmitting(false); @@ -160,7 +177,11 @@ export default function ProfilePage() { }; if (isLoading || isLoadingProfile) { - return ; + return ( +
+
Loading...
+
+ ); } if (!user || !isAuthorized) { @@ -173,7 +194,14 @@ export default function ProfilePage() {
{/* Toast notification */} {toast && ( - setToast(null)} /> +
+ {toast.message} +
)}
diff --git a/frontend/app/styles/auth-form.ts b/frontend/app/styles/auth-form.ts index efe6701..4ef086b 100644 --- a/frontend/app/styles/auth-form.ts +++ b/frontend/app/styles/auth-form.ts @@ -1,21 +1,12 @@ 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: { @@ -23,41 +14,80 @@ export const authFormStyles: Record = { maxWidth: "420px", }, card: { - ...cardStyles.card, + background: "rgba(255, 255, 255, 0.03)", + backdropFilter: "blur(10px)", + border: "1px solid rgba(255, 255, 255, 0.08)", + borderRadius: "24px", padding: "3rem 2.5rem", + boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)", }, header: { textAlign: "center" as const, marginBottom: "2.5rem", }, title: { - ...typographyStyles.pageTitle, + fontFamily: "'Instrument Serif', Georgia, serif", fontSize: "2.5rem", - textAlign: "center" as const, + fontWeight: 400, + color: "#fff", + margin: 0, + letterSpacing: "-0.02em", }, subtitle: { - ...typographyStyles.pageSubtitle, - textAlign: "center" as const, + fontFamily: "'DM Sans', system-ui, sans-serif", + color: "rgba(255, 255, 255, 0.5)", + marginTop: "0.5rem", + fontSize: "0.95rem", }, form: { - ...formStyles.form, + display: "flex", + flexDirection: "column" as const, gap: "1.5rem", }, field: { - ...formStyles.field, + display: "flex", + flexDirection: "column" as const, + gap: "0.5rem", }, label: { - ...formStyles.label, + fontFamily: "'DM Sans', system-ui, sans-serif", + color: "rgba(255, 255, 255, 0.7)", + fontSize: "0.875rem", + fontWeight: 500, }, input: { - ...formStyles.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", }, button: { - ...buttonStyles.primaryButton, + fontFamily: "'DM Sans', system-ui, sans-serif", 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: { - ...bannerStyles.errorBanner, + 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", textAlign: "center" as const, }, footer: { diff --git a/frontend/app/trades/page.tsx b/frontend/app/trades/page.tsx index f9e7a13..0eff03f 100644 --- a/frontend/app/trades/page.tsx +++ b/frontend/app/trades/page.tsx @@ -6,7 +6,6 @@ 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"; @@ -69,7 +68,11 @@ export default function TradesPage() { }; if (isLoading) { - return ; + return ( +
+
Loading...
+
+ ); } if (!isAuthorized) { diff --git a/frontend/app/utils/error-handling.ts b/frontend/app/utils/error-handling.ts deleted file mode 100644 index 101e0cc..0000000 --- a/frontend/app/utils/error-handling.ts +++ /dev/null @@ -1,50 +0,0 @@ -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; -}