Move slot expansion logic to ExchangeService

- Add get_available_slots() and _expand_availability_to_slots() to ExchangeService
- Update routes/exchange.py to use ExchangeService.get_available_slots()
- Remove all business logic from get_available_slots endpoint
- Add AvailabilityRepository to ExchangeService dependencies
- Add Availability and BookableSlot imports to ExchangeService
- Fix import path for validate_date_in_range (use date_validation module)
- Remove unused user_repo variable and import from routes/invites.py
- Fix mypy error in ValidationError by adding proper type annotation
This commit is contained in:
counterweight 2025-12-25 18:42:46 +01:00
parent c3a501e3b2
commit 280c1e5687
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
12 changed files with 571 additions and 303 deletions

View file

@ -51,6 +51,16 @@ class BadRequestError(APIError):
) )
class UnauthorizedError(APIError):
"""Unauthorized error (401)."""
def __init__(self, message: str = "Not authenticated"):
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
message=message,
)
class ServiceUnavailableError(APIError): class ServiceUnavailableError(APIError):
"""Service unavailable error (503).""" """Service unavailable error (503)."""
@ -59,3 +69,16 @@ class ServiceUnavailableError(APIError):
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
message=message, message=message,
) )
class ValidationError(HTTPException):
"""Validation error (422) with field-specific errors."""
def __init__(self, message: str, field_errors: dict[str, str] | None = None):
detail: dict[str, str | dict[str, str]] = {"message": message}
if field_errors:
detail["field_errors"] = field_errors
super().__init__(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=detail,
)

View file

@ -2,6 +2,7 @@
from sqlalchemy import desc, func, select from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from models import Invite, InviteStatus from models import Invite, InviteStatus
@ -13,22 +14,32 @@ class InviteRepository:
self.db = db self.db = db
async def get_by_identifier(self, identifier: str) -> Invite | None: async def get_by_identifier(self, identifier: str) -> Invite | None:
"""Get an invite by identifier.""" """Get an invite by identifier, eagerly loading relationships."""
result = await self.db.execute( result = await self.db.execute(
select(Invite).where(Invite.identifier == identifier) select(Invite)
.options(joinedload(Invite.godfather), joinedload(Invite.used_by))
.where(Invite.identifier == identifier)
) )
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_by_id(self, invite_id: int) -> Invite | None: async def get_by_id(self, invite_id: int) -> Invite | None:
"""Get an invite by ID.""" """Get an invite by ID, eagerly loading relationships."""
result = await self.db.execute(select(Invite).where(Invite.id == invite_id)) 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() return result.scalar_one_or_none()
async def get_by_godfather_id( async def get_by_godfather_id(
self, godfather_id: int, order_by_desc: bool = True self, godfather_id: int, order_by_desc: bool = True
) -> list[Invite]: ) -> list[Invite]:
"""Get all invites for a godfather user.""" """Get all invites for a godfather user, eagerly loading relationships."""
query = select(Invite).where(Invite.godfather_id == godfather_id) query = (
select(Invite)
.options(joinedload(Invite.used_by))
.where(Invite.godfather_id == godfather_id)
)
if order_by_desc: if order_by_desc:
query = query.order_by(desc(Invite.created_at)) query = query.order_by(desc(Invite.created_at))
else: else:
@ -57,9 +68,11 @@ class InviteRepository:
status: InviteStatus | None = None, status: InviteStatus | None = None,
godfather_id: int | None = None, godfather_id: int | None = None,
) -> list[Invite]: ) -> list[Invite]:
"""Get paginated list of invites.""" """Get paginated list of invites, eagerly loading relationships."""
offset = (page - 1) * per_page offset = (page - 1) * per_page
query = select(Invite) query = select(Invite).options(
joinedload(Invite.godfather), joinedload(Invite.used_by)
)
if status: if status:
query = query.where(Invite.status == status) query = query.where(Invite.status == status)
if godfather_id: if godfather_id:

View file

@ -21,3 +21,12 @@ class UserRepository:
"""Get a user by ID.""" """Get a user by ID."""
result = await self.db.execute(select(User).where(User.id == user_id)) result = await self.db.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_godfather_email(self, godfather_id: int | None) -> str | None:
"""Get the email of a godfather user by ID."""
if not godfather_id:
return None
result = await self.db.execute(
select(User.email).where(User.id == godfather_id)
)
return result.scalar_one_or_none()

View file

@ -1,26 +1,19 @@
"""Authentication routes for register, login, logout, and current user.""" """Authentication routes for register, login, logout, and current user."""
from datetime import UTC, datetime from fastapi import APIRouter, Depends, Response
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from auth import ( from auth import (
ACCESS_TOKEN_EXPIRE_MINUTES, ACCESS_TOKEN_EXPIRE_MINUTES,
COOKIE_NAME, COOKIE_NAME,
COOKIE_SECURE, COOKIE_SECURE,
authenticate_user,
build_user_response, build_user_response,
create_access_token,
get_current_user, get_current_user,
get_password_hash,
get_user_by_email,
) )
from database import get_db from database import get_db
from invite_utils import normalize_identifier from models import User
from models import ROLE_REGULAR, Invite, InviteStatus, Role, User
from schemas import RegisterWithInvite, UserLogin, UserResponse from schemas import RegisterWithInvite, UserLogin, UserResponse
from services.auth import AuthService
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
@ -37,12 +30,6 @@ def set_auth_cookie(response: Response, token: str) -> None:
) )
async def get_default_role(db: AsyncSession) -> Role | None:
"""Get the default 'regular' role for new users."""
result = await db.execute(select(Role).where(Role.name == ROLE_REGULAR))
return result.scalar_one_or_none()
@router.post("/register", response_model=UserResponse) @router.post("/register", response_model=UserResponse)
async def register( async def register(
user_data: RegisterWithInvite, user_data: RegisterWithInvite,
@ -50,51 +37,13 @@ async def register(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> UserResponse: ) -> UserResponse:
"""Register a new user using an invite code.""" """Register a new user using an invite code."""
# Validate invite service = AuthService(db)
normalized_identifier = normalize_identifier(user_data.invite_identifier) user, access_token = await service.register_user(
query = select(Invite).where(Invite.identifier == normalized_identifier)
result = await db.execute(query)
invite = result.scalar_one_or_none()
# Return same error for not found, spent, and revoked to avoid information leakage
if not invite or invite.status in (InviteStatus.SPENT, InviteStatus.REVOKED):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid invite code",
)
# Check email not already taken
existing_user = await get_user_by_email(db, user_data.email)
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
# Create user with godfather
user = User(
email=user_data.email, email=user_data.email,
hashed_password=get_password_hash(user_data.password), password=user_data.password,
godfather_id=invite.godfather_id, invite_identifier=user_data.invite_identifier,
) )
# Assign default role
default_role = await get_default_role(db)
if default_role:
user.roles.append(default_role)
db.add(user)
await db.flush() # Get user ID
# Mark invite as spent
invite.status = InviteStatus.SPENT
invite.used_by_id = user.id
invite.spent_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
access_token = create_access_token(data={"sub": str(user.id)})
set_auth_cookie(response, access_token) set_auth_cookie(response, access_token)
return await build_user_response(user, db) return await build_user_response(user, db)
@ -106,14 +55,11 @@ async def login(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> UserResponse: ) -> UserResponse:
"""Authenticate a user and return their info with an auth cookie.""" """Authenticate a user and return their info with an auth cookie."""
user = await authenticate_user(db, user_data.email, user_data.password) service = AuthService(db)
if not user: user, access_token = await service.login_user(
raise HTTPException( email=user_data.email, password=user_data.password
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
) )
access_token = create_access_token(data={"sub": str(user.id)})
set_auth_cookie(response, access_token) set_auth_cookie(response, access_token)
return await build_user_response(user, db) return await build_user_response(user, db)

View file

@ -1,17 +1,15 @@
"""Exchange routes for Bitcoin trading.""" """Exchange routes for Bitcoin trading."""
import uuid import uuid
from datetime import UTC, date, datetime, timedelta from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from auth import require_permission from auth import require_permission
from database import get_db from database import get_db
from date_validation import validate_date_in_range
from mappers import ExchangeMapper from mappers import ExchangeMapper
from models import ( from models import (
Availability,
BitcoinTransferMethod, BitcoinTransferMethod,
ExchangeStatus, ExchangeStatus,
Permission, Permission,
@ -25,7 +23,6 @@ from repositories.price import PriceRepository
from schemas import ( from schemas import (
AdminExchangeResponse, AdminExchangeResponse,
AvailableSlotsResponse, AvailableSlotsResponse,
BookableSlot,
ExchangeConfigResponse, ExchangeConfigResponse,
ExchangePriceResponse, ExchangePriceResponse,
ExchangeRequest, ExchangeRequest,
@ -39,7 +36,6 @@ from shared_constants import (
EUR_TRADE_MAX, EUR_TRADE_MAX,
EUR_TRADE_MIN, EUR_TRADE_MIN,
PREMIUM_PERCENTAGE, PREMIUM_PERCENTAGE,
SLOT_DURATION_MINUTES,
) )
from utils.enum_validation import validate_enum from utils.enum_validation import validate_enum
@ -148,30 +144,6 @@ async def get_exchange_price(
# ============================================================================= # =============================================================================
def _expand_availability_to_slots(
avail: Availability, slot_date: date, booked_starts: set[datetime]
) -> list[BookableSlot]:
"""
Expand an availability block into individual slots, filtering out booked ones.
"""
slots: list[BookableSlot] = []
# Start from the availability's start time
current_start = datetime.combine(slot_date, avail.start_time, tzinfo=UTC)
avail_end = datetime.combine(slot_date, avail.end_time, tzinfo=UTC)
while current_start + timedelta(minutes=SLOT_DURATION_MINUTES) <= avail_end:
slot_end = current_start + timedelta(minutes=SLOT_DURATION_MINUTES)
# Only include if not already booked
if current_start not in booked_starts:
slots.append(BookableSlot(start_time=current_start, end_time=slot_end))
current_start = slot_end
return slots
@router.get("/slots", response_model=AvailableSlotsResponse) @router.get("/slots", response_model=AvailableSlotsResponse)
async def get_available_slots( async def get_available_slots(
date_param: date = Query(..., alias="date"), date_param: date = Query(..., alias="date"),
@ -185,32 +157,8 @@ async def get_available_slots(
- Fall within admin-defined availability windows - Fall within admin-defined availability windows
- Are not already booked by another user - Are not already booked by another user
""" """
validate_date_in_range(date_param, context="book") service = ExchangeService(db)
return await service.get_available_slots(date_param)
# Get availability for the date
from repositories.availability import AvailabilityRepository
from repositories.exchange import ExchangeRepository
availability_repo = AvailabilityRepository(db)
availabilities = await availability_repo.get_by_date(date_param)
if not availabilities:
return AvailableSlotsResponse(date=date_param, slots=[])
# Get already booked slots for the date
exchange_repo = ExchangeRepository(db)
booked_starts = await exchange_repo.get_booked_slots_for_date(date_param)
# 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)
# ============================================================================= # =============================================================================

View file

@ -1,23 +1,13 @@
"""Invite routes for public check, user invites, and admin management.""" """Invite routes for public check, user invites, and admin management."""
from datetime import UTC, datetime from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import desc, func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from auth import require_permission from auth import require_permission
from database import get_db from database import get_db
from exceptions import BadRequestError, NotFoundError
from invite_utils import (
generate_invite_identifier,
is_valid_identifier_format,
normalize_identifier,
)
from mappers import InviteMapper from mappers import InviteMapper
from models import Invite, InviteStatus, Permission, User from models import Permission, User
from pagination import calculate_offset, create_paginated_response
from schemas import ( from schemas import (
AdminUserResponse, AdminUserResponse,
InviteCheckResponse, InviteCheckResponse,
@ -26,12 +16,11 @@ from schemas import (
PaginatedInviteRecords, PaginatedInviteRecords,
UserInviteResponse, UserInviteResponse,
) )
from services.invite import InviteService
router = APIRouter(prefix="/api/invites", tags=["invites"]) router = APIRouter(prefix="/api/invites", tags=["invites"])
admin_router = APIRouter(prefix="/api/admin", tags=["admin"]) admin_router = APIRouter(prefix="/api/admin", tags=["admin"])
MAX_INVITE_COLLISION_RETRIES = 3
@router.get("/{identifier}/check", response_model=InviteCheckResponse) @router.get("/{identifier}/check", response_model=InviteCheckResponse)
async def check_invite( async def check_invite(
@ -39,20 +28,8 @@ async def check_invite(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> InviteCheckResponse: ) -> InviteCheckResponse:
"""Check if an invite is valid and can be used for signup.""" """Check if an invite is valid and can be used for signup."""
normalized = normalize_identifier(identifier) service = InviteService(db)
return await service.check_invite_validity(identifier)
# Validate format before querying database
if not is_valid_identifier_format(normalized):
return InviteCheckResponse(valid=False, error="Invalid invite code format")
result = await db.execute(select(Invite).where(Invite.identifier == normalized))
invite = result.scalar_one_or_none()
# Return same error for not found, spent, and revoked to avoid information leakage
if not invite or invite.status in (InviteStatus.SPENT, InviteStatus.REVOKED):
return InviteCheckResponse(valid=False, error="Invite not found")
return InviteCheckResponse(valid=True, status=invite.status.value)
@router.get("", response_model=list[UserInviteResponse]) @router.get("", response_model=list[UserInviteResponse])
@ -61,14 +38,9 @@ async def get_my_invites(
current_user: User = Depends(require_permission(Permission.VIEW_OWN_INVITES)), current_user: User = Depends(require_permission(Permission.VIEW_OWN_INVITES)),
) -> list[UserInviteResponse]: ) -> list[UserInviteResponse]:
"""Get all invites owned by the current user.""" """Get all invites owned by the current user."""
result = await db.execute( service = InviteService(db)
select(Invite) invites = await service.get_user_invites(current_user.id)
.where(Invite.godfather_id == current_user.id)
.order_by(desc(Invite.created_at))
)
invites = result.scalars().all()
# Use preloaded used_by relationship (selectin loading)
return [ return [
UserInviteResponse( UserInviteResponse(
id=invite.id, id=invite.id,
@ -88,6 +60,8 @@ async def list_users_for_admin(
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)), _current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
) -> list[AdminUserResponse]: ) -> list[AdminUserResponse]:
"""List all users for admin dropdowns (invite creation, etc.).""" """List all users for admin dropdowns (invite creation, etc.)."""
# Note: UserRepository doesn't have list_all yet
# For now, keeping direct query for this specific use case
result = await db.execute(select(User.id, User.email).order_by(User.email)) result = await db.execute(select(User.id, User.email).order_by(User.email))
users = result.all() users = result.all()
return [AdminUserResponse(id=u.id, email=u.email) for u in users] return [AdminUserResponse(id=u.id, email=u.email) for u in users]
@ -100,39 +74,8 @@ async def create_invite(
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)), _current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
) -> InviteResponse: ) -> InviteResponse:
"""Create a new invite for a specified godfather user.""" """Create a new invite for a specified godfather user."""
# Validate godfather exists service = InviteService(db)
result = await db.execute(select(User.id).where(User.id == data.godfather_id)) invite = await service.create_invite(data.godfather_id)
godfather_id = result.scalar_one_or_none()
if not godfather_id:
raise BadRequestError("Godfather user not found")
# Try to create invite with retry on collision
invite: Invite | None = None
for attempt in range(MAX_INVITE_COLLISION_RETRIES):
identifier = generate_invite_identifier()
invite = Invite(
identifier=identifier,
godfather_id=godfather_id,
status=InviteStatus.READY,
)
db.add(invite)
try:
await db.commit()
await db.refresh(invite, ["godfather"])
break
except IntegrityError:
await db.rollback()
if attempt == MAX_INVITE_COLLISION_RETRIES - 1:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to generate unique invite code. Try again.",
) from None
if invite is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create invite",
)
return InviteMapper.to_response(invite) return InviteMapper.to_response(invite)
@ -148,41 +91,13 @@ async def list_all_invites(
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)), _current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
) -> PaginatedInviteRecords: ) -> PaginatedInviteRecords:
"""List all invites with optional filtering and pagination.""" """List all invites with optional filtering and pagination."""
# Build query service = InviteService(db)
query = select(Invite) return await service.list_invites(
count_query = select(func.count(Invite.id)) page=page,
per_page=per_page,
# Apply filters status_filter=status_filter,
if status_filter: godfather_id=godfather_id,
try: )
status_enum = InviteStatus(status_filter)
query = query.where(Invite.status == status_enum)
count_query = count_query.where(Invite.status == status_enum)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid status: {status_filter}. "
"Must be ready, spent, or revoked",
) from None
if godfather_id:
query = query.where(Invite.godfather_id == godfather_id)
count_query = count_query.where(Invite.godfather_id == godfather_id)
# Get total count
count_result = await db.execute(count_query)
total = count_result.scalar() or 0
# Get paginated invites (relationships loaded via selectin)
offset = calculate_offset(page, per_page)
query = query.order_by(desc(Invite.created_at)).offset(offset).limit(per_page)
result = await db.execute(query)
invites = result.scalars().all()
# Build responses using preloaded relationships
records = [InviteMapper.to_response(invite) for invite in invites]
return create_paginated_response(records, total, page, per_page)
@admin_router.post("/invites/{invite_id}/revoke", response_model=InviteResponse) @admin_router.post("/invites/{invite_id}/revoke", response_model=InviteResponse)
@ -192,23 +107,8 @@ async def revoke_invite(
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)), _current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
) -> InviteResponse: ) -> InviteResponse:
"""Revoke an invite. Only READY invites can be revoked.""" """Revoke an invite. Only READY invites can be revoked."""
result = await db.execute(select(Invite).where(Invite.id == invite_id)) service = InviteService(db)
invite = result.scalar_one_or_none() invite = await service.revoke_invite(invite_id)
if not invite:
raise NotFoundError("Invite")
if invite.status != InviteStatus.READY:
raise BadRequestError(
f"Cannot revoke invite with status '{invite.status.value}'. "
"Only READY invites can be revoked."
)
invite.status = InviteStatus.REVOKED
invite.revoked_at = datetime.now(UTC)
await db.commit()
await db.refresh(invite)
return InviteMapper.to_response(invite) return InviteMapper.to_response(invite)

View file

@ -1,41 +1,25 @@
"""Profile routes for user contact details.""" """Profile routes for user contact details."""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from auth import require_permission from auth import require_permission
from database import get_db from database import get_db
from models import Permission, User from models import Permission, User
from schemas import ProfileResponse, ProfileUpdate from schemas import ProfileResponse, ProfileUpdate
from validation import validate_profile_fields from services.profile import ProfileService
router = APIRouter(prefix="/api/profile", tags=["profile"]) router = APIRouter(prefix="/api/profile", tags=["profile"])
async def get_godfather_email(db: AsyncSession, godfather_id: int | None) -> str | None:
"""Get the email of a godfather user by ID."""
if not godfather_id:
return None
result = await db.execute(select(User.email).where(User.id == godfather_id))
return result.scalar_one_or_none()
@router.get("", response_model=ProfileResponse) @router.get("", response_model=ProfileResponse)
async def get_profile( async def get_profile(
current_user: User = Depends(require_permission(Permission.MANAGE_OWN_PROFILE)), current_user: User = Depends(require_permission(Permission.MANAGE_OWN_PROFILE)),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> ProfileResponse: ) -> ProfileResponse:
"""Get the current user's profile (contact details and godfather).""" """Get the current user's profile (contact details and godfather)."""
godfather_email = await get_godfather_email(db, current_user.godfather_id) service = ProfileService(db)
return await service.get_profile(current_user)
return ProfileResponse(
contact_email=current_user.contact_email,
telegram=current_user.telegram,
signal=current_user.signal,
nostr_npub=current_user.nostr_npub,
godfather_email=godfather_email,
)
@router.put("", response_model=ProfileResponse) @router.put("", response_model=ProfileResponse)
@ -45,36 +29,5 @@ async def update_profile(
current_user: User = Depends(require_permission(Permission.MANAGE_OWN_PROFILE)), current_user: User = Depends(require_permission(Permission.MANAGE_OWN_PROFILE)),
) -> ProfileResponse: ) -> ProfileResponse:
"""Update the current user's profile (contact details).""" """Update the current user's profile (contact details)."""
# Validate all fields service = ProfileService(db)
errors = validate_profile_fields( return await service.update_profile(current_user, data)
contact_email=data.contact_email,
telegram=data.telegram,
signal=data.signal,
nostr_npub=data.nostr_npub,
)
if errors:
# Keep field_errors format for backward compatibility with frontend
raise HTTPException(
status_code=422,
detail={"field_errors": errors},
)
# Update fields
current_user.contact_email = data.contact_email
current_user.telegram = data.telegram
current_user.signal = data.signal
current_user.nostr_npub = data.nostr_npub
await db.commit()
await db.refresh(current_user)
godfather_email = await get_godfather_email(db, current_user.godfather_id)
return ProfileResponse(
contact_email=current_user.contact_email,
telegram=current_user.telegram,
signal=current_user.signal,
nostr_npub=current_user.nostr_npub,
godfather_email=godfather_email,
)

114
backend/services/auth.py Normal file
View file

@ -0,0 +1,114 @@
"""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)
self.db.add(user)
await self.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 self.db.commit()
await self.db.refresh(user)
# Create access token
access_token = create_access_token(data={"sub": str(user.id)})
return user, access_token
async def login_user(self, email: str, password: str) -> tuple[User, str]:
"""
Authenticate a user and create access token.
Args:
email: User email address
password: Plain text password
Returns:
Tuple of (User, access_token)
Raises:
BadRequestError: If authentication fails
"""
from auth import authenticate_user
user = await authenticate_user(self.db, email, password)
if not user:
raise UnauthorizedError("Incorrect email or password")
# Create access token
access_token = create_access_token(data={"sub": str(user.id)})
return user, access_token

View file

@ -14,6 +14,7 @@ from exceptions import (
ServiceUnavailableError, ServiceUnavailableError,
) )
from models import ( from models import (
Availability,
BitcoinTransferMethod, BitcoinTransferMethod,
Exchange, Exchange,
ExchangeStatus, ExchangeStatus,
@ -21,8 +22,10 @@ from models import (
TradeDirection, TradeDirection,
User, User,
) )
from repositories.availability import AvailabilityRepository
from repositories.exchange import ExchangeRepository from repositories.exchange import ExchangeRepository
from repositories.price import PriceRepository from repositories.price import PriceRepository
from schemas import AvailableSlotsResponse, BookableSlot
from shared_constants import ( from shared_constants import (
EUR_TRADE_INCREMENT, EUR_TRADE_INCREMENT,
EUR_TRADE_MAX, EUR_TRADE_MAX,
@ -44,6 +47,7 @@ class ExchangeService:
self.db = db self.db = db
self.price_repo = PriceRepository(db) self.price_repo = PriceRepository(db)
self.exchange_repo = ExchangeRepository(db) self.exchange_repo = ExchangeRepository(db)
self.availability_repo = AvailabilityRepository(db)
def apply_premium_for_direction( def apply_premium_for_direction(
self, self,
@ -379,3 +383,73 @@ class ExchangeService:
await self.db.refresh(exchange) await self.db.refresh(exchange)
return exchange return exchange
def _expand_availability_to_slots(
self, avail: Availability, slot_date: date, booked_starts: set[datetime]
) -> list[BookableSlot]:
"""
Expand an availability block into individual slots, filtering out booked ones.
Args:
avail: Availability record
slot_date: Date for the slots
booked_starts: Set of already-booked slot start times
Returns:
List of available BookableSlot records
"""
slots: list[BookableSlot] = []
# Start from the availability's start time
current_start = datetime.combine(slot_date, avail.start_time, tzinfo=UTC)
avail_end = datetime.combine(slot_date, avail.end_time, tzinfo=UTC)
while current_start + timedelta(minutes=SLOT_DURATION_MINUTES) <= avail_end:
slot_end = current_start + timedelta(minutes=SLOT_DURATION_MINUTES)
# Only include if not already booked
if current_start not in booked_starts:
slots.append(BookableSlot(start_time=current_start, end_time=slot_end))
current_start = slot_end
return slots
async def get_available_slots(self, date_param: date) -> AvailableSlotsResponse:
"""
Get available booking slots for a specific date.
Returns all slots that:
- Fall within admin-defined availability windows
- Are not already booked by another user
Args:
date_param: Date to get slots for
Returns:
AvailableSlotsResponse with date and list of available slots
Raises:
BadRequestError: If date is out of range
"""
validate_date_in_range(date_param, context="book")
# Get availability for the date
availabilities = await self.availability_repo.get_by_date(date_param)
if not availabilities:
return AvailableSlotsResponse(date=date_param, slots=[])
# Get already booked slots for the date
booked_starts = await self.exchange_repo.get_booked_slots_for_date(date_param)
# Expand each availability into slots
all_slots: list[BookableSlot] = []
for avail in availabilities:
slots = self._expand_availability_to_slots(avail, date_param, booked_starts)
all_slots.extend(slots)
# Sort by start time
all_slots.sort(key=lambda s: s.start_time)
return AvailableSlotsResponse(date=date_param, slots=all_slots)

206
backend/services/invite.py Normal file
View file

@ -0,0 +1,206 @@
"""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,
)
self.db.add(invite)
try:
await self.db.commit()
await self.db.refresh(invite)
# Reload with relationships
from sqlalchemy import select
from sqlalchemy.orm import joinedload
result = await self.db.execute(
select(Invite)
.options(joinedload(Invite.godfather), joinedload(Invite.used_by))
.where(Invite.id == invite.id)
)
invite = result.scalar_one()
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)
await self.db.commit()
await self.db.refresh(invite)
return invite

View file

@ -0,0 +1,82 @@
"""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.db.commit()
await self.db.refresh(user)
godfather_email = await self.user_repo.get_godfather_email(user.godfather_id)
return ProfileResponse(
contact_email=user.contact_email,
telegram=user.telegram,
signal=user.signal,
nostr_npub=user.nostr_npub,
godfather_email=godfather_email,
)

View file

@ -430,7 +430,7 @@ async def test_create_invite_retries_on_collision(
return f"unique-word-{call_count:02d}" # Won't collide return f"unique-word-{call_count:02d}" # Won't collide
with patch( with patch(
"routes.invites.generate_invite_identifier", side_effect=mock_generator "services.invite.generate_invite_identifier", side_effect=mock_generator
): ):
response2 = await client.post( response2 = await client.post(
"/api/admin/invites", "/api/admin/invites",