feat: [api] Create the Register and Onboarding endpoints (API and BFF)

This commit is contained in:
wilson 2026-04-11 08:01:03 +01:00
parent 36d09b2304
commit 88c355053f
11 changed files with 395 additions and 99 deletions

View file

@ -1,4 +1,5 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
@ -6,54 +7,69 @@ from sqlalchemy.ext.asyncio import AsyncSession
from ..models.account import Account
from ..models.learnable_language import LearnableLanguage
from ...auth import hash_password
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 learnable_language_repository, user_repository
from ...outbound.postgres.repositories import (
email_verification_token_repository,
learnable_language_repository,
user_repository,
)
class AccountService:
"""Handles account-level operations: registration, profile retrieval, and managing
the set of languages a user is learning.
All methods operate on behalf of a single authenticated user (or, for
``create_account``, the user being created).
"""Handles account-level operations: registration, authentication, email
verification, and managing the set of languages a user is learning.
Usage::
service = AccountService(db)
# Registration
account = await service.create_account("alice@example.com", "s3cr3t")
# 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)
print(account.learnable_languages) # [LearnableLanguage(...), ...]
# Add French (B1) to the account
lang = await service.add_learnable_language(
user_id, source_language="en", target_language="fr", proficiencies=["B1"]
)
# Remove it again
# 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) -> None:
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()
async def create_account(self, email: str, password: str) -> Account:
"""Create a new user account, hashing the plain-text password before storage.
# ------------------------------------------------------------------
# Registration & authentication
# ------------------------------------------------------------------
Raises ``ValueError`` if the email address is already registered, so the
caller does not need to catch SQLAlchemy exceptions directly.
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::
try:
account = await service.create_account("alice@example.com", "s3cr3t")
except ValueError:
# email already taken
...
account, link = await service.register_new_account("alice@example.com", "s3cr3t")
"""
try:
user = await user_repository.create(
@ -65,6 +81,59 @@ class AccountService:
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,
@ -73,6 +142,36 @@ class AccountService:
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.
@ -84,7 +183,6 @@ class AccountService:
for lang in account.learnable_languages:
print(lang.target_language, lang.proficiencies)
"""
# user_repository only exposes get_by_email; query by id directly
result = await self.db.execute(
select(UserEntity).where(UserEntity.id == user_id)
)
@ -103,6 +201,10 @@ class AccountService:
learnable_languages=languages,
)
# ------------------------------------------------------------------
# Language management
# ------------------------------------------------------------------
async def add_learnable_language(
self,
user_id: uuid.UUID,
@ -121,7 +223,6 @@ class AccountService:
target_language="fr",
proficiencies=["B1", "B2"],
)
print(lang.id) # UUID of the learnable_language row
"""
return await learnable_language_repository.upsert(
self.db,
@ -136,8 +237,8 @@ class AccountService:
) -> 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``.
Raises ``ValueError`` if the language entry does not exist or does not
belong to ``user_id``.
Usage::

View file

@ -7,3 +7,15 @@ SUPPORTED_LANGUAGES: dict[str, str] = {
}
SUPPORTED_LEVELS = {"A1", "A2", "B1", "B2", "C1", "C2"}
# Ordered for display (SUPPORTED_LEVELS is a set and has no guaranteed order).
LEVEL_ORDER: list[str] = ["A1", "A2", "B1", "B2", "C1", "C2"]
LEVEL_DESCRIPTIONS: dict[str, str] = {
"A1": "Little to no prior knowledge of the language",
"A2": "Basic knowledge; can understand simple phrases and expressions",
"B1": "Intermediate; can handle most everyday situations",
"B2": "Upper intermediate; can interact fluently with native speakers",
"C1": "Advanced; able to use language flexibly and effectively",
"C2": "Near-native mastery of the language",
}

View file

@ -5,7 +5,6 @@ from .routers.api import generation, pos
from fastapi import FastAPI
from .routers.api import jobs
from .routers import auth as auth_router
from .routers import media as media_router
from .routers.api.main import api_router
from .routers.bff.main import bff_router
@ -29,7 +28,6 @@ app = FastAPI(title="Language Learning API", lifespan=lifespan)
app.include_router(api_router)
app.include_router(bff_router)
app.include_router(auth_router.router)
app.include_router(media_router.router)

View file

@ -0,0 +1,33 @@
import secrets
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import DateTime, ForeignKey, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from ..database import Base
TOKEN_TTL_HOURS = 24
class EmailVerificationToken(Base):
__tablename__ = "email_verification_tokens"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
token: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@staticmethod
def make(user_id: uuid.UUID) -> "EmailVerificationToken":
return EmailVerificationToken(
user_id=user_id,
token=secrets.token_urlsafe(32),
expires_at=datetime.now(timezone.utc) + timedelta(hours=TOKEN_TTL_HOURS),
)

View file

@ -17,8 +17,7 @@ class User(Base):
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# TODO(email-verification): set to False and require verification once transactional email is implemented
is_email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
is_email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),

View file

@ -0,0 +1,44 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..entities.email_verification_token_entity import EmailVerificationToken
async def create(db: AsyncSession, user_id: uuid.UUID) -> EmailVerificationToken:
row = EmailVerificationToken.make(user_id)
db.add(row)
await db.commit()
await db.refresh(row)
return row
async def get_by_token(db: AsyncSession, token: str) -> EmailVerificationToken | None:
result = await db.execute(
select(EmailVerificationToken).where(EmailVerificationToken.token == token)
)
return result.scalar_one_or_none()
async def get_active_for_user(db: AsyncSession, user_id: uuid.UUID) -> EmailVerificationToken | None:
"""Return the most recently created unused, unexpired token for this user."""
result = await db.execute(
select(EmailVerificationToken)
.where(EmailVerificationToken.user_id == user_id)
.where(EmailVerificationToken.used_at.is_(None))
.where(EmailVerificationToken.expires_at > datetime.now(timezone.utc))
.order_by(EmailVerificationToken.expires_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def mark_used(db: AsyncSession, token_id: uuid.UUID) -> None:
result = await db.execute(
select(EmailVerificationToken).where(EmailVerificationToken.id == token_id)
)
row = result.scalar_one()
row.used_at = datetime.now(timezone.utc)
await db.commit()

View file

@ -0,0 +1,88 @@
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, EmailStr, field_validator
from sqlalchemy.ext.asyncio import AsyncSession
from ...auth import create_access_token
from ...domain.services.account_service import AccountService
from ...outbound.postgres.database import get_db
router = APIRouter(prefix="/auth", tags=["auth"])
class RegisterRequest(BaseModel):
email: EmailStr
password: str
@field_validator("password")
@classmethod
def password_min_length(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("Password must be at least 8 characters long")
return v
class RegisterResponse(BaseModel):
success: bool
error_message: str | None = None
_todo_remove_me_validate_account_link: str | None = None
model_config = {"populate_by_name": True}
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
@router.post("/register", response_model=RegisterResponse, status_code=status.HTTP_201_CREATED)
async def register(
body: RegisterRequest,
db: AsyncSession = Depends(get_db),
) -> RegisterResponse:
try:
account, link = await AccountService(db).register_new_account(body.email, body.password)
except ValueError:
return RegisterResponse(
success=False,
error_message="Email address not valid or already in use",
)
return RegisterResponse(
success=True,
**{"_todo_remove_me_validate_account_link": link},
)
@router.post("/login", response_model=TokenResponse)
async def login(
body: LoginRequest,
db: AsyncSession = Depends(get_db),
) -> TokenResponse:
try:
account = await AccountService(db).authenticate_with_password(body.email, body.password)
except ValueError as exc:
if str(exc) == "account_disabled":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account disabled")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password"
)
return TokenResponse(access_token=create_access_token(account.id, account.email))
@router.get("/verify-email")
async def verify_email(
token: str,
db: AsyncSession = Depends(get_db),
) -> dict:
try:
await AccountService(db).verify_email_address(token)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
return {"success": True}

View file

@ -1,4 +1,5 @@
from .account import router as account_router
from .auth import router as auth_router
from .flashcards import router as flashcards_router
from .pos import router as pos_router
from .translate import router as translate_router
@ -11,6 +12,7 @@ from fastapi import APIRouter
api_router = APIRouter(prefix="/api", tags=["api"])
api_router.include_router(auth_router)
api_router.include_router(account_router)
api_router.include_router(flashcards_router)
api_router.include_router(pos_router)

View file

@ -1,65 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, EmailStr
from sqlalchemy.ext.asyncio import AsyncSession
from ..auth import create_access_token, verify_password
from ..domain.services.account_service import AccountService
from ..outbound.postgres.database import get_db
from ..outbound.postgres.repositories import user_repository
router = APIRouter(prefix="/auth", tags=["auth"])
class RegisterRequest(BaseModel):
email: EmailStr
password: str
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
@router.post("/register", status_code=status.HTTP_201_CREATED)
async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)):
# TODO(email-verification): send verification email here once transactional
# email is implemented. Set is_email_verified=False on the User model and
# require verification before allowing login.
try:
account = await AccountService(db).create_account(body.email, body.password)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
return {"id": account.id, "email": account.email}
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
user = await user_repository.get_by_email(db, body.email)
if user is None or not verify_password(body.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account disabled",
)
# TODO(email-verification): uncomment once email verification is in place
# if not user.is_email_verified:
# raise HTTPException(
# status_code=status.HTTP_403_FORBIDDEN,
# detail="Email address not verified",
# )
token = create_access_token(str(user.id), user.email)
return TokenResponse(access_token=token)

View file

@ -0,0 +1,82 @@
import uuid
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from ...auth import verify_token
from ...config import settings
from ...languages import SUPPORTED_LANGUAGES, SUPPORTED_LEVELS, LEVEL_ORDER, LEVEL_DESCRIPTIONS
from ...outbound.postgres.database import get_db
from ...outbound.postgres.repositories import email_verification_token_repository
router = APIRouter(prefix="/account", tags=["bff"])
class LanguagePairOption(BaseModel):
value: str
label: str
description: str
class ProficiencyOption(BaseModel):
value: str
label: str
description: str
class OnboardingResponse(BaseModel):
_todo_remove_me_validate_account_link: str | None = None
language_pairs: list[LanguagePairOption]
proficiencies: list[ProficiencyOption]
model_config = {"populate_by_name": True}
def _build_language_pairs() -> list[LanguagePairOption]:
pairs = []
for source_code, source_name in SUPPORTED_LANGUAGES.items():
for target_code, target_name in SUPPORTED_LANGUAGES.items():
if source_code == target_code:
continue
pairs.append(
LanguagePairOption(
value=f"{source_code},{target_code}",
label=f"{source_name} to {target_name}",
description=f"You are a {source_name} speaker, learning {target_name}",
)
)
return pairs
def _build_proficiencies() -> list[ProficiencyOption]:
return [
ProficiencyOption(
value=level,
label=level,
description=LEVEL_DESCRIPTIONS[level],
)
for level in LEVEL_ORDER
if level in SUPPORTED_LEVELS
]
@router.get("/onboarding", response_model=OnboardingResponse)
async def get_onboarding(
db: AsyncSession = Depends(get_db),
token_data: dict = Depends(verify_token),
) -> OnboardingResponse:
user_id = uuid.UUID(token_data["sub"])
active_token = await email_verification_token_repository.get_active_for_user(db, user_id)
link = (
f"{settings.api_base_url}/api/auth/verify-email?token={active_token.token}"
if active_token
else None
)
return OnboardingResponse(
**{"_todo_remove_me_validate_account_link": link},
language_pairs=_build_language_pairs(),
proficiencies=_build_proficiencies(),
)

View file

@ -1,3 +1,4 @@
from .account import router as account_router
from .articles import router as article_router
from .user_profile import router as user_profile_router
@ -5,5 +6,6 @@ from fastapi import APIRouter
bff_router = APIRouter(prefix="/bff", tags=["bff"])
bff_router.include_router(account_router)
bff_router.include_router(article_router)
bff_router.include_router(user_profile_router)