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

308 lines
11 KiB
Python
Raw Normal View History

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,
human_name=user.human_name,
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,
human_name=user.human_name,
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,
human_name=user.human_name,
is_active=user.is_active,
is_email_verified=user.is_email_verified,
created_at=user.created_at,
learnable_languages=languages,
)
# ------------------------------------------------------------------
# Onboarding
# ------------------------------------------------------------------
async def complete_onboarding(
self,
user_id: uuid.UUID,
human_name: str,
language_pair: str,
proficiencies: list[str],
) -> Account:
"""Record the user's name and first language pair, completing onboarding.
``language_pair`` is a comma-separated ``"source,target"`` string (e.g. ``"en,fr"``).
Usage::
account = await service.complete_onboarding(
user_id, human_name="Alice", language_pair="en,fr", proficiencies=["B1"]
)
"""
source, target = language_pair.split(",", 1)
await user_repository.set_human_name(self.db, user_id, human_name)
await self.add_learnable_language(user_id, source, target, proficiencies)
return await self.get_account(user_id)
async def get_account_status(self, user_id: uuid.UUID) -> tuple[list[str], list[str]]:
"""Return ``(problem_flags, error_messages)`` describing any blockers on the account.
Current flags:
- ``unvalidated_email`` the user has not verified their email address
- ``no_onboarding`` the user has not added any language pairs yet
Usage::
flags, messages = await service.get_account_status(user_id)
if flags:
... # surface to the user
"""
account = await self.get_account(user_id)
flags: list[str] = []
messages: list[str] = []
if not account.is_email_verified:
flags.append("unvalidated_email")
messages.append("Please validate your email address")
if not account.learnable_languages:
flags.append("no_onboarding")
messages.append("Please complete onboarding")
return flags, messages
# ------------------------------------------------------------------
# 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")