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

316 lines
11 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)
"""
@staticmethod
def _is_admin_email(email: str) -> bool:
admin_emails = frozenset(
e.strip() for e in settings.admin_user_emails.split(",") if e.strip()
)
return email in admin_emails
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,
is_admin=self._is_admin_email(user.email),
)
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,
is_admin=self._is_admin_email(user.email),
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")