diff --git a/backend/exceptions.py b/backend/exceptions.py index 7e8a641..4435fbf 100644 --- a/backend/exceptions.py +++ b/backend/exceptions.py @@ -51,6 +51,16 @@ class BadRequestError(APIError): ) +class UnauthorizedError(APIError): + """Unauthorized error (401).""" + + def __init__(self, message: str = "Not authenticated"): + super().__init__( + status_code=status.HTTP_401_UNAUTHORIZED, + message=message, + ) + + class ServiceUnavailableError(APIError): """Service unavailable error (503).""" @@ -59,3 +69,16 @@ class ServiceUnavailableError(APIError): status_code=status.HTTP_503_SERVICE_UNAVAILABLE, message=message, ) + + +class ValidationError(HTTPException): + """Validation error (422) with field-specific errors.""" + + def __init__(self, message: str, field_errors: dict[str, str] | None = None): + detail: dict[str, str | dict[str, str]] = {"message": message} + if field_errors: + detail["field_errors"] = field_errors + super().__init__( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=detail, + ) diff --git a/backend/repositories/invite.py b/backend/repositories/invite.py index 8790439..3fa3a01 100644 --- a/backend/repositories/invite.py +++ b/backend/repositories/invite.py @@ -2,6 +2,7 @@ from sqlalchemy import desc, func, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload from models import Invite, InviteStatus @@ -13,22 +14,32 @@ class InviteRepository: self.db = db 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( - 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() async def get_by_id(self, invite_id: int) -> Invite | None: - """Get an invite by ID.""" - result = await self.db.execute(select(Invite).where(Invite.id == invite_id)) + """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.""" - query = select(Invite).where(Invite.godfather_id == godfather_id) + """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: @@ -57,9 +68,11 @@ class InviteRepository: status: InviteStatus | None = None, godfather_id: int | None = None, ) -> list[Invite]: - """Get paginated list of invites.""" + """Get paginated list of invites, eagerly loading relationships.""" offset = (page - 1) * per_page - query = select(Invite) + query = select(Invite).options( + joinedload(Invite.godfather), joinedload(Invite.used_by) + ) if status: query = query.where(Invite.status == status) if godfather_id: diff --git a/backend/repositories/user.py b/backend/repositories/user.py index d4c12ce..833c825 100644 --- a/backend/repositories/user.py +++ b/backend/repositories/user.py @@ -21,3 +21,12 @@ 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() diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 604b50d..f228ab1 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -1,26 +1,19 @@ """Authentication routes for register, login, logout, and current user.""" -from datetime import UTC, datetime - -from fastapi import APIRouter, Depends, HTTPException, Response, status -from sqlalchemy import select +from fastapi import APIRouter, Depends, Response from sqlalchemy.ext.asyncio import AsyncSession from auth import ( ACCESS_TOKEN_EXPIRE_MINUTES, COOKIE_NAME, COOKIE_SECURE, - authenticate_user, build_user_response, - create_access_token, get_current_user, - get_password_hash, - get_user_by_email, ) from database import get_db -from invite_utils import normalize_identifier -from models import ROLE_REGULAR, Invite, InviteStatus, Role, User +from models import User from schemas import RegisterWithInvite, UserLogin, UserResponse +from services.auth import AuthService router = APIRouter(prefix="/api/auth", tags=["auth"]) @@ -37,12 +30,6 @@ def set_auth_cookie(response: Response, token: str) -> None: ) -async def get_default_role(db: AsyncSession) -> Role | None: - """Get the default 'regular' role for new users.""" - result = await db.execute(select(Role).where(Role.name == ROLE_REGULAR)) - return result.scalar_one_or_none() - - @router.post("/register", response_model=UserResponse) async def register( user_data: RegisterWithInvite, @@ -50,51 +37,13 @@ async def register( db: AsyncSession = Depends(get_db), ) -> UserResponse: """Register a new user using an invite code.""" - # Validate invite - normalized_identifier = normalize_identifier(user_data.invite_identifier) - query = select(Invite).where(Invite.identifier == normalized_identifier) - result = await db.execute(query) - invite = result.scalar_one_or_none() - - # Return same error for not found, spent, and revoked to avoid information leakage - if not invite or invite.status in (InviteStatus.SPENT, InviteStatus.REVOKED): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid invite code", - ) - - # Check email not already taken - existing_user = await get_user_by_email(db, user_data.email) - if existing_user: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Email already registered", - ) - - # Create user with godfather - user = User( + service = AuthService(db) + user, access_token = await service.register_user( email=user_data.email, - hashed_password=get_password_hash(user_data.password), - godfather_id=invite.godfather_id, + password=user_data.password, + invite_identifier=user_data.invite_identifier, ) - # Assign default role - default_role = await get_default_role(db) - if default_role: - user.roles.append(default_role) - - db.add(user) - await db.flush() # Get user ID - - # Mark invite as spent - invite.status = InviteStatus.SPENT - invite.used_by_id = user.id - invite.spent_at = datetime.now(UTC) - - await db.commit() - await db.refresh(user) - - access_token = create_access_token(data={"sub": str(user.id)}) set_auth_cookie(response, access_token) return await build_user_response(user, db) @@ -106,14 +55,11 @@ async def login( db: AsyncSession = Depends(get_db), ) -> UserResponse: """Authenticate a user and return their info with an auth cookie.""" - user = await authenticate_user(db, user_data.email, user_data.password) - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect email or password", - ) + service = AuthService(db) + user, access_token = await service.login_user( + email=user_data.email, password=user_data.password + ) - access_token = create_access_token(data={"sub": str(user.id)}) set_auth_cookie(response, access_token) return await build_user_response(user, db) diff --git a/backend/routes/exchange.py b/backend/routes/exchange.py index 21690ea..95e20ea 100644 --- a/backend/routes/exchange.py +++ b/backend/routes/exchange.py @@ -1,17 +1,15 @@ """Exchange routes for Bitcoin trading.""" import uuid -from datetime import UTC, date, datetime, timedelta +from datetime import date from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession from auth import require_permission from database import get_db -from date_validation import validate_date_in_range from mappers import ExchangeMapper from models import ( - Availability, BitcoinTransferMethod, ExchangeStatus, Permission, @@ -25,7 +23,6 @@ from repositories.price import PriceRepository from schemas import ( AdminExchangeResponse, AvailableSlotsResponse, - BookableSlot, ExchangeConfigResponse, ExchangePriceResponse, ExchangeRequest, @@ -39,7 +36,6 @@ from shared_constants import ( EUR_TRADE_MAX, EUR_TRADE_MIN, PREMIUM_PERCENTAGE, - SLOT_DURATION_MINUTES, ) 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) async def get_available_slots( date_param: date = Query(..., alias="date"), @@ -185,32 +157,8 @@ async def get_available_slots( - Fall within admin-defined availability windows - Are not already booked by another user """ - validate_date_in_range(date_param, context="book") - - # Get availability for the date - 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) + service = ExchangeService(db) + return await service.get_available_slots(date_param) # ============================================================================= diff --git a/backend/routes/invites.py b/backend/routes/invites.py index 7e50472..812ab3c 100644 --- a/backend/routes/invites.py +++ b/backend/routes/invites.py @@ -1,23 +1,13 @@ """Invite routes for public check, user invites, and admin management.""" -from datetime import UTC, datetime - -from fastapi import APIRouter, Depends, HTTPException, Query, status -from sqlalchemy import desc, func, select -from sqlalchemy.exc import IntegrityError +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from auth import require_permission from database import get_db -from exceptions import BadRequestError, NotFoundError -from invite_utils import ( - generate_invite_identifier, - is_valid_identifier_format, - normalize_identifier, -) from mappers import InviteMapper -from models import Invite, InviteStatus, Permission, User -from pagination import calculate_offset, create_paginated_response +from models import Permission, User from schemas import ( AdminUserResponse, InviteCheckResponse, @@ -26,12 +16,11 @@ from schemas import ( PaginatedInviteRecords, UserInviteResponse, ) +from services.invite import InviteService router = APIRouter(prefix="/api/invites", tags=["invites"]) admin_router = APIRouter(prefix="/api/admin", tags=["admin"]) -MAX_INVITE_COLLISION_RETRIES = 3 - @router.get("/{identifier}/check", response_model=InviteCheckResponse) async def check_invite( @@ -39,20 +28,8 @@ async def check_invite( db: AsyncSession = Depends(get_db), ) -> InviteCheckResponse: """Check if an invite is valid and can be used for signup.""" - normalized = normalize_identifier(identifier) - - # Validate format before querying database - if not is_valid_identifier_format(normalized): - return InviteCheckResponse(valid=False, error="Invalid invite code format") - - result = await db.execute(select(Invite).where(Invite.identifier == normalized)) - invite = result.scalar_one_or_none() - - # Return same error for not found, spent, and revoked to avoid information leakage - if not invite or invite.status in (InviteStatus.SPENT, InviteStatus.REVOKED): - return InviteCheckResponse(valid=False, error="Invite not found") - - return InviteCheckResponse(valid=True, status=invite.status.value) + service = InviteService(db) + return await service.check_invite_validity(identifier) @router.get("", response_model=list[UserInviteResponse]) @@ -61,14 +38,9 @@ async def get_my_invites( current_user: User = Depends(require_permission(Permission.VIEW_OWN_INVITES)), ) -> list[UserInviteResponse]: """Get all invites owned by the current user.""" - result = await db.execute( - select(Invite) - .where(Invite.godfather_id == current_user.id) - .order_by(desc(Invite.created_at)) - ) - invites = result.scalars().all() + service = InviteService(db) + invites = await service.get_user_invites(current_user.id) - # Use preloaded used_by relationship (selectin loading) return [ UserInviteResponse( id=invite.id, @@ -88,6 +60,8 @@ async def list_users_for_admin( _current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)), ) -> list[AdminUserResponse]: """List all users for admin dropdowns (invite creation, etc.).""" + # Note: UserRepository doesn't have list_all yet + # For now, keeping direct query for this specific use case result = await db.execute(select(User.id, User.email).order_by(User.email)) users = result.all() return [AdminUserResponse(id=u.id, email=u.email) for u in users] @@ -100,39 +74,8 @@ async def create_invite( _current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)), ) -> InviteResponse: """Create a new invite for a specified godfather user.""" - # Validate godfather exists - result = await db.execute(select(User.id).where(User.id == data.godfather_id)) - godfather_id = result.scalar_one_or_none() - if not godfather_id: - raise BadRequestError("Godfather user not found") - - # Try to create invite with retry on collision - invite: Invite | None = None - for attempt in range(MAX_INVITE_COLLISION_RETRIES): - identifier = generate_invite_identifier() - invite = Invite( - identifier=identifier, - godfather_id=godfather_id, - status=InviteStatus.READY, - ) - db.add(invite) - try: - await db.commit() - await db.refresh(invite, ["godfather"]) - break - except IntegrityError: - await db.rollback() - if attempt == MAX_INVITE_COLLISION_RETRIES - 1: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to generate unique invite code. Try again.", - ) from None - - if invite is None: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create invite", - ) + service = InviteService(db) + invite = await service.create_invite(data.godfather_id) return InviteMapper.to_response(invite) @@ -148,41 +91,13 @@ async def list_all_invites( _current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)), ) -> PaginatedInviteRecords: """List all invites with optional filtering and pagination.""" - # Build query - query = select(Invite) - count_query = select(func.count(Invite.id)) - - # Apply filters - if status_filter: - try: - status_enum = InviteStatus(status_filter) - query = query.where(Invite.status == status_enum) - count_query = count_query.where(Invite.status == status_enum) - except ValueError: - raise HTTPException( - status_code=400, - detail=f"Invalid status: {status_filter}. " - "Must be ready, spent, or revoked", - ) from None - - if godfather_id: - query = query.where(Invite.godfather_id == godfather_id) - count_query = count_query.where(Invite.godfather_id == godfather_id) - - # Get total count - count_result = await db.execute(count_query) - total = count_result.scalar() or 0 - - # Get paginated invites (relationships loaded via selectin) - offset = calculate_offset(page, per_page) - query = query.order_by(desc(Invite.created_at)).offset(offset).limit(per_page) - result = await db.execute(query) - invites = result.scalars().all() - - # Build responses using preloaded relationships - records = [InviteMapper.to_response(invite) for invite in invites] - - return create_paginated_response(records, total, page, per_page) + service = InviteService(db) + return await service.list_invites( + page=page, + per_page=per_page, + status_filter=status_filter, + godfather_id=godfather_id, + ) @admin_router.post("/invites/{invite_id}/revoke", response_model=InviteResponse) @@ -192,23 +107,8 @@ async def revoke_invite( _current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)), ) -> InviteResponse: """Revoke an invite. Only READY invites can be revoked.""" - result = await db.execute(select(Invite).where(Invite.id == invite_id)) - invite = result.scalar_one_or_none() - - if not invite: - raise NotFoundError("Invite") - - if invite.status != InviteStatus.READY: - raise BadRequestError( - f"Cannot revoke invite with status '{invite.status.value}'. " - "Only READY invites can be revoked." - ) - - invite.status = InviteStatus.REVOKED - invite.revoked_at = datetime.now(UTC) - await db.commit() - await db.refresh(invite) - + service = InviteService(db) + invite = await service.revoke_invite(invite_id) return InviteMapper.to_response(invite) diff --git a/backend/routes/profile.py b/backend/routes/profile.py index bd294cb..5c474cf 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -1,41 +1,25 @@ """Profile routes for user contact details.""" -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import select +from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession from auth import require_permission from database import get_db from models import Permission, User from schemas import ProfileResponse, ProfileUpdate -from validation import validate_profile_fields +from services.profile import ProfileService router = APIRouter(prefix="/api/profile", tags=["profile"]) -async def get_godfather_email(db: AsyncSession, godfather_id: int | None) -> str | None: - """Get the email of a godfather user by ID.""" - if not godfather_id: - return None - result = await db.execute(select(User.email).where(User.id == godfather_id)) - return result.scalar_one_or_none() - - @router.get("", response_model=ProfileResponse) async def get_profile( current_user: User = Depends(require_permission(Permission.MANAGE_OWN_PROFILE)), db: AsyncSession = Depends(get_db), ) -> ProfileResponse: """Get the current user's profile (contact details and godfather).""" - godfather_email = await get_godfather_email(db, current_user.godfather_id) - - return ProfileResponse( - contact_email=current_user.contact_email, - telegram=current_user.telegram, - signal=current_user.signal, - nostr_npub=current_user.nostr_npub, - godfather_email=godfather_email, - ) + service = ProfileService(db) + return await service.get_profile(current_user) @router.put("", response_model=ProfileResponse) @@ -45,36 +29,5 @@ async def update_profile( current_user: User = Depends(require_permission(Permission.MANAGE_OWN_PROFILE)), ) -> ProfileResponse: """Update the current user's profile (contact details).""" - # Validate all fields - errors = validate_profile_fields( - contact_email=data.contact_email, - telegram=data.telegram, - signal=data.signal, - nostr_npub=data.nostr_npub, - ) - - if errors: - # Keep field_errors format for backward compatibility with frontend - raise HTTPException( - status_code=422, - detail={"field_errors": errors}, - ) - - # Update fields - current_user.contact_email = data.contact_email - current_user.telegram = data.telegram - current_user.signal = data.signal - current_user.nostr_npub = data.nostr_npub - - await db.commit() - await db.refresh(current_user) - - godfather_email = await get_godfather_email(db, current_user.godfather_id) - - return ProfileResponse( - contact_email=current_user.contact_email, - telegram=current_user.telegram, - signal=current_user.signal, - nostr_npub=current_user.nostr_npub, - godfather_email=godfather_email, - ) + service = ProfileService(db) + return await service.update_profile(current_user, data) diff --git a/backend/services/auth.py b/backend/services/auth.py new file mode 100644 index 0000000..ff31a54 --- /dev/null +++ b/backend/services/auth.py @@ -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 diff --git a/backend/services/exchange.py b/backend/services/exchange.py index f21b03d..2491726 100644 --- a/backend/services/exchange.py +++ b/backend/services/exchange.py @@ -14,6 +14,7 @@ from exceptions import ( ServiceUnavailableError, ) from models import ( + Availability, BitcoinTransferMethod, Exchange, ExchangeStatus, @@ -21,8 +22,10 @@ from models import ( TradeDirection, User, ) +from repositories.availability import AvailabilityRepository from repositories.exchange import ExchangeRepository from repositories.price import PriceRepository +from schemas import AvailableSlotsResponse, BookableSlot from shared_constants import ( EUR_TRADE_INCREMENT, EUR_TRADE_MAX, @@ -44,6 +47,7 @@ class ExchangeService: self.db = db self.price_repo = PriceRepository(db) self.exchange_repo = ExchangeRepository(db) + self.availability_repo = AvailabilityRepository(db) def apply_premium_for_direction( self, @@ -379,3 +383,73 @@ class ExchangeService: await self.db.refresh(exchange) return exchange + + def _expand_availability_to_slots( + self, avail: Availability, slot_date: date, booked_starts: set[datetime] + ) -> list[BookableSlot]: + """ + Expand an availability block into individual slots, filtering out booked ones. + + Args: + avail: Availability record + slot_date: Date for the slots + booked_starts: Set of already-booked slot start times + + Returns: + List of available BookableSlot records + """ + slots: list[BookableSlot] = [] + + # Start from the availability's start time + current_start = datetime.combine(slot_date, avail.start_time, tzinfo=UTC) + avail_end = datetime.combine(slot_date, avail.end_time, tzinfo=UTC) + + while current_start + timedelta(minutes=SLOT_DURATION_MINUTES) <= avail_end: + slot_end = current_start + timedelta(minutes=SLOT_DURATION_MINUTES) + + # Only include if not already booked + if current_start not in booked_starts: + slots.append(BookableSlot(start_time=current_start, end_time=slot_end)) + + current_start = slot_end + + return slots + + async def get_available_slots(self, date_param: date) -> AvailableSlotsResponse: + """ + Get available booking slots for a specific date. + + Returns all slots that: + - Fall within admin-defined availability windows + - Are not already booked by another user + + Args: + date_param: Date to get slots for + + Returns: + AvailableSlotsResponse with date and list of available slots + + Raises: + BadRequestError: If date is out of range + """ + validate_date_in_range(date_param, context="book") + + # Get availability for the date + availabilities = await self.availability_repo.get_by_date(date_param) + + if not availabilities: + return AvailableSlotsResponse(date=date_param, slots=[]) + + # Get already booked slots for the date + booked_starts = await self.exchange_repo.get_booked_slots_for_date(date_param) + + # Expand each availability into slots + all_slots: list[BookableSlot] = [] + for avail in availabilities: + slots = self._expand_availability_to_slots(avail, date_param, booked_starts) + all_slots.extend(slots) + + # Sort by start time + all_slots.sort(key=lambda s: s.start_time) + + return AvailableSlotsResponse(date=date_param, slots=all_slots) diff --git a/backend/services/invite.py b/backend/services/invite.py new file mode 100644 index 0000000..5816bfe --- /dev/null +++ b/backend/services/invite.py @@ -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 diff --git a/backend/services/profile.py b/backend/services/profile.py new file mode 100644 index 0000000..8f63bba --- /dev/null +++ b/backend/services/profile.py @@ -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, + ) diff --git a/backend/tests/test_invites.py b/backend/tests/test_invites.py index 20042ea..e63fa5b 100644 --- a/backend/tests/test_invites.py +++ b/backend/tests/test_invites.py @@ -430,7 +430,7 @@ async def test_create_invite_retries_on_collision( return f"unique-word-{call_count:02d}" # Won't collide with patch( - "routes.invites.generate_invite_identifier", side_effect=mock_generator + "services.invite.generate_invite_identifier", side_effect=mock_generator ): response2 = await client.post( "/api/admin/invites",