feat: [api] Add Onboarding, and account validation endpoints

This commit is contained in:
wilson 2026-04-11 08:08:10 +01:00
parent 88c355053f
commit 3a3537831a
7 changed files with 193 additions and 1 deletions

View file

@ -0,0 +1,24 @@
"""add human_name to users
Revision ID: 0012
Revises: 0011
Create Date: 2026-04-11
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0012"
down_revision: Union[str, None] = "0011"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("users", sa.Column("human_name", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("users", "human_name")

View file

@ -11,4 +11,5 @@ class Account:
is_active: bool
is_email_verified: bool
created_at: datetime
human_name: str | None = None
learnable_languages: list[LearnableLanguage] = field(default_factory=list)

View file

@ -84,6 +84,7 @@ class AccountService:
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,
@ -137,6 +138,7 @@ class AccountService:
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,
@ -195,12 +197,66 @@ class AccountService:
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
# ------------------------------------------------------------------

View file

@ -16,6 +16,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)
human_name: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: 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(

View file

@ -1,3 +1,5 @@
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@ -15,3 +17,10 @@ async def create(db: AsyncSession, email: str, hashed_password: str) -> User:
async def get_by_email(db: AsyncSession, email: str) -> User | None:
result = await db.execute(select(User).where(User.email == email))
return result.scalar_one_or_none()
async def set_human_name(db: AsyncSession, user_id: uuid.UUID, human_name: str) -> None:
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one()
user.human_name = human_name
await db.commit()

View file

@ -1,7 +1,7 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, field_validator
from pydantic import BaseModel, field_validator, model_validator
from sqlalchemy.ext.asyncio import AsyncSession
from ...auth import verify_token
@ -76,6 +76,71 @@ async def add_learnable_language(
)
class OnboardingRequest(BaseModel):
human_name: str
language_pairs: list[str]
proficiencies: list[list[str]]
@model_validator(mode="after")
def validate_shape(self) -> "OnboardingRequest":
if len(self.language_pairs) != 1:
raise ValueError("language_pairs must contain exactly one entry")
if len(self.proficiencies) != 1:
raise ValueError("proficiencies must contain exactly one entry")
pair = self.language_pairs[0]
parts = pair.split(",")
if len(parts) != 2:
raise ValueError(f"language_pairs entry must be 'source,target', got {pair!r}")
source, target = parts
if source not in SUPPORTED_LANGUAGES:
raise ValueError(f"Unsupported source language: {source!r}")
if target not in SUPPORTED_LANGUAGES:
raise ValueError(f"Unsupported target language: {target!r}")
if source == target:
raise ValueError("Source and target language must differ")
levels = self.proficiencies[0]
if not (1 <= len(levels) <= 2):
raise ValueError("proficiencies entry must contain 1 or 2 levels")
invalid = [l for l in levels if l not in SUPPORTED_LEVELS]
if invalid:
raise ValueError(f"Invalid proficiency levels: {invalid}")
return self
class AccountStatusResponse(BaseModel):
problem_flags: list[str]
error_messages: list[str]
@router.post("/onboarding", status_code=status.HTTP_200_OK)
async def complete_onboarding(
body: OnboardingRequest,
db: AsyncSession = Depends(get_db),
token_data: dict = Depends(verify_token),
) -> dict:
user_id = uuid.UUID(token_data["sub"])
await AccountService(db).complete_onboarding(
user_id=user_id,
human_name=body.human_name,
language_pair=body.language_pairs[0],
proficiencies=body.proficiencies[0],
)
return {"success": True}
@router.get("/status", response_model=AccountStatusResponse)
async def get_account_status(
db: AsyncSession = Depends(get_db),
token_data: dict = Depends(verify_token),
) -> AccountStatusResponse:
user_id = uuid.UUID(token_data["sub"])
flags, messages = await AccountService(db).get_account_status(user_id)
return AccountStatusResponse(problem_flags=flags, error_messages=messages)
@router.delete(
"/learnable-languages/{language_id}",
status_code=status.HTTP_204_NO_CONTENT,

View file

@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from ...auth import verify_token
from ...config import settings
from ...domain.services.account_service import AccountService
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
@ -61,6 +62,41 @@ def _build_proficiencies() -> list[ProficiencyOption]:
]
class AccountLanguagePair(BaseModel):
id: str
source_language: str
target_language: str
proficiencies: list[str]
class AccountResponse(BaseModel):
email: str
human_name: str | None
language_pairs: list[AccountLanguagePair]
@router.get("", response_model=AccountResponse)
async def get_account(
db: AsyncSession = Depends(get_db),
token_data: dict = Depends(verify_token),
) -> AccountResponse:
user_id = uuid.UUID(token_data["sub"])
account = await AccountService(db).get_account(user_id)
return AccountResponse(
email=account.email,
human_name=account.human_name,
language_pairs=[
AccountLanguagePair(
id=lang.id,
source_language=lang.source_language,
target_language=lang.target_language,
proficiencies=lang.proficiencies,
)
for lang in account.learnable_languages
],
)
@router.get("/onboarding", response_model=OnboardingResponse)
async def get_onboarding(
db: AsyncSession = Depends(get_db),