import uuid from datetime import datetime, timezone from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from ..models.account import Account from ..models.learnable_language import LearnableLanguage from ...auth import hash_password, verify_password from ...config import settings from ...outbound.email.protocol import TransactionalEmailClient from ...outbound.postgres.entities.user_entity import User as UserEntity from ...outbound.postgres.repositories import ( email_verification_token_repository, learnable_language_repository, user_repository, ) class AccountService: """Handles account-level operations: registration, authentication, email verification, and managing the set of languages a user is learning. Usage:: service = AccountService(db) # Registration — returns the new account and the verification link account, link = await service.register_new_account("alice@example.com", "s3cr3t") # Email verification await service.verify_email_address(token_from_link) # Authentication — raises ValueError on bad credentials or disabled account account = await service.authenticate_with_password("alice@example.com", "s3cr3t") # Profile retrieval account = await service.get_account(user_id) # Language management lang = await service.add_learnable_language(user_id, "en", "fr", ["B1"]) await service.remove_learnable_language(user_id, lang.id) """ def __init__( self, db: AsyncSession, email_client: TransactionalEmailClient | None = None, ) -> None: self.db = db # Defer import to avoid circular dependency at module load time. if email_client is not None: self._email_client = email_client else: from ...outbound.email.factory import get_email_client self._email_client = get_email_client() # ------------------------------------------------------------------ # Registration & authentication # ------------------------------------------------------------------ async def register_new_account(self, email: str, password: str) -> tuple[Account, str]: """Create a new account, generate an email verification token, and send the verification email. Returns ``(account, verification_link)`` on success. Raises ``ValueError`` if the email address is already registered. Usage:: account, link = await service.register_new_account("alice@example.com", "s3cr3t") """ try: user = await user_repository.create( self.db, email=email, hashed_password=hash_password(password), ) except IntegrityError: await self.db.rollback() raise ValueError("Email already registered") account = Account( id=str(user.id), email=user.email, is_active=user.is_active, is_email_verified=user.is_email_verified, created_at=user.created_at, ) token_row = await email_verification_token_repository.create( self.db, uuid.UUID(account.id) ) link = f"{settings.api_base_url}/api/auth/verify-email?token={token_row.token}" await self._email_client.send_email( to=account.email, subject="Verify your email address", html_body=( f"
Thanks for signing up! Please verify your email address by clicking the link below:
" f'' f"This link expires in 24 hours.
" ), ) return account, link async def authenticate_with_password(self, email: str, password: str) -> Account: """Validate credentials and return the matching account. Raises ``ValueError("invalid_credentials")`` for an unrecognised email or wrong password, and ``ValueError("account_disabled")`` if the account has been deactivated. Usage:: try: account = await service.authenticate_with_password(email, password) except ValueError as exc: if str(exc) == "account_disabled": ... # 403 ... # 401 """ user = await user_repository.get_by_email(self.db, email) if user is None or not verify_password(password, user.hashed_password): raise ValueError("invalid_credentials") if not user.is_active: raise ValueError("account_disabled") # TODO(email-verification): uncomment once email verification is tested end-to-end # if not user.is_email_verified: # raise ValueError("email_not_verified") return Account( id=str(user.id), email=user.email, is_active=user.is_active, is_email_verified=user.is_email_verified, created_at=user.created_at, ) async def verify_email_address(self, token: str) -> None: """Consume a verification token and mark the account as email-verified. Raises ``ValueError`` if the token is invalid, already used, or expired. Usage:: await service.verify_email_address(token_from_query_string) """ token_row = await email_verification_token_repository.get_by_token(self.db, token) if token_row is None or token_row.used_at is not None: raise ValueError("Verification link is invalid or has already been used") if token_row.expires_at < datetime.now(timezone.utc): raise ValueError("Verification link has expired") await email_verification_token_repository.mark_used(self.db, token_row.id) result = await self.db.execute( select(UserEntity).where(UserEntity.id == token_row.user_id) ) user = result.scalar_one() user.is_email_verified = True await self.db.commit() # ------------------------------------------------------------------ # Profile # ------------------------------------------------------------------ async def get_account(self, user_id: uuid.UUID) -> Account: """Retrieve a user's account profile including all their learnable languages. Raises ``ValueError`` if no user exists for the given ``user_id``. Usage:: account = await service.get_account(user_id) for lang in account.learnable_languages: print(lang.target_language, lang.proficiencies) """ result = await self.db.execute( select(UserEntity).where(UserEntity.id == user_id) ) user = result.scalar_one_or_none() if user is None: raise ValueError(f"User {user_id} not found") languages = await learnable_language_repository.list_for_user(self.db, user_id) return Account( id=str(user.id), email=user.email, is_active=user.is_active, is_email_verified=user.is_email_verified, created_at=user.created_at, learnable_languages=languages, ) # ------------------------------------------------------------------ # Language management # ------------------------------------------------------------------ async def add_learnable_language( self, user_id: uuid.UUID, source_language: str, target_language: str, proficiencies: list[str], ) -> LearnableLanguage: """Add a language pair to the user's account, or update proficiency levels if the pair already exists (upsert semantics). Usage:: lang = await service.add_learnable_language( user_id, source_language="en", target_language="fr", proficiencies=["B1", "B2"], ) """ return await learnable_language_repository.upsert( self.db, user_id=user_id, source_language=source_language, target_language=target_language, proficiencies=proficiencies, ) async def remove_learnable_language( self, user_id: uuid.UUID, language_id: uuid.UUID ) -> None: """Remove a learnable language from the user's account by its row ID. Raises ``ValueError`` if the language entry does not exist or does not belong to ``user_id``. Usage:: await service.remove_learnable_language(user_id, lang.id) """ deleted = await learnable_language_repository.delete( self.db, user_id=user_id, language_id=language_id ) if not deleted: raise ValueError(f"Learnable language {language_id} not found for this user")