2026-04-08 19:50:26 +00:00
|
|
|
import uuid
|
2026-04-11 07:01:03 +00:00
|
|
|
from datetime import datetime, timezone
|
2026-04-08 19:50:26 +00:00
|
|
|
|
|
|
|
|
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
|
2026-04-11 07:01:03 +00:00
|
|
|
from ...auth import hash_password, verify_password
|
|
|
|
|
from ...config import settings
|
|
|
|
|
from ...outbound.email.protocol import TransactionalEmailClient
|
2026-04-08 19:50:26 +00:00
|
|
|
from ...outbound.postgres.entities.user_entity import User as UserEntity
|
2026-04-11 07:01:03 +00:00
|
|
|
from ...outbound.postgres.repositories import (
|
|
|
|
|
email_verification_token_repository,
|
|
|
|
|
learnable_language_repository,
|
|
|
|
|
user_repository,
|
|
|
|
|
)
|
2026-04-08 19:50:26 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class AccountService:
|
2026-04-11 07:01:03 +00:00
|
|
|
"""Handles account-level operations: registration, authentication, email
|
|
|
|
|
verification, and managing the set of languages a user is learning.
|
2026-04-08 19:50:26 +00:00
|
|
|
|
|
|
|
|
Usage::
|
|
|
|
|
|
|
|
|
|
service = AccountService(db)
|
|
|
|
|
|
2026-04-11 07:01:03 +00:00
|
|
|
# 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")
|
2026-04-08 19:50:26 +00:00
|
|
|
|
|
|
|
|
# Profile retrieval
|
|
|
|
|
account = await service.get_account(user_id)
|
|
|
|
|
|
2026-04-11 07:01:03 +00:00
|
|
|
# Language management
|
|
|
|
|
lang = await service.add_learnable_language(user_id, "en", "fr", ["B1"])
|
2026-04-08 19:50:26 +00:00
|
|
|
await service.remove_learnable_language(user_id, lang.id)
|
|
|
|
|
"""
|
|
|
|
|
|
2026-04-11 07:01:03 +00:00
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
db: AsyncSession,
|
|
|
|
|
email_client: TransactionalEmailClient | None = None,
|
|
|
|
|
) -> None:
|
2026-04-08 19:50:26 +00:00
|
|
|
self.db = db
|
2026-04-11 07:01:03 +00:00
|
|
|
# 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
|
|
|
|
|
# ------------------------------------------------------------------
|
2026-04-08 19:50:26 +00:00
|
|
|
|
2026-04-11 07:01:03 +00:00
|
|
|
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.
|
2026-04-08 19:50:26 +00:00
|
|
|
|
2026-04-11 07:01:03 +00:00
|
|
|
Returns ``(account, verification_link)`` on success.
|
|
|
|
|
Raises ``ValueError`` if the email address is already registered.
|
2026-04-08 19:50:26 +00:00
|
|
|
|
|
|
|
|
Usage::
|
|
|
|
|
|
2026-04-11 07:01:03 +00:00
|
|
|
account, link = await service.register_new_account("alice@example.com", "s3cr3t")
|
2026-04-08 19:50:26 +00:00
|
|
|
"""
|
|
|
|
|
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")
|
|
|
|
|
|
2026-04-11 07:01:03 +00:00
|
|
|
account = Account(
|
|
|
|
|
id=str(user.id),
|
|
|
|
|
email=user.email,
|
2026-04-11 07:08:10 +00:00
|
|
|
human_name=user.human_name,
|
2026-04-11 07:01:03 +00:00
|
|
|
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")
|
|
|
|
|
|
2026-04-08 19:50:26 +00:00
|
|
|
return Account(
|
|
|
|
|
id=str(user.id),
|
|
|
|
|
email=user.email,
|
2026-04-11 07:08:10 +00:00
|
|
|
human_name=user.human_name,
|
2026-04-08 19:50:26 +00:00
|
|
|
is_active=user.is_active,
|
|
|
|
|
is_email_verified=user.is_email_verified,
|
|
|
|
|
created_at=user.created_at,
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-11 07:01:03 +00:00
|
|
|
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
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
2026-04-08 19:50:26 +00:00
|
|
|
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,
|
2026-04-11 07:08:10 +00:00
|
|
|
human_name=user.human_name,
|
2026-04-08 19:50:26 +00:00
|
|
|
is_active=user.is_active,
|
|
|
|
|
is_email_verified=user.is_email_verified,
|
|
|
|
|
created_at=user.created_at,
|
|
|
|
|
learnable_languages=languages,
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-11 07:08:10 +00:00
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# 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
|
|
|
|
|
|
2026-04-11 07:01:03 +00:00
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Language management
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
2026-04-08 19:50:26 +00:00
|
|
|
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.
|
|
|
|
|
|
2026-04-11 07:01:03 +00:00
|
|
|
Raises ``ValueError`` if the language entry does not exist or does not
|
|
|
|
|
belong to ``user_id``.
|
2026-04-08 19:50:26 +00:00
|
|
|
|
|
|
|
|
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")
|