language-learning-app/api/app/domain/services/account_service.py

251 lines
8.8 KiB
Python

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"<p>Thanks for signing up! Please verify your email address by clicking the link below:</p>"
f'<p><a href="{link}">{link}</a></p>'
f"<p>This link expires in 24 hours.</p>"
),
)
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")