diff --git a/api/alembic/versions/20260411_0012_add_human_name_to_users.py b/api/alembic/versions/20260411_0012_add_human_name_to_users.py new file mode 100644 index 0000000..6f591e2 --- /dev/null +++ b/api/alembic/versions/20260411_0012_add_human_name_to_users.py @@ -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") diff --git a/api/app/domain/models/account.py b/api/app/domain/models/account.py index 95811aa..9aa58ad 100644 --- a/api/app/domain/models/account.py +++ b/api/app/domain/models/account.py @@ -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) diff --git a/api/app/domain/services/account_service.py b/api/app/domain/services/account_service.py index cbef85b..7dd3351 100644 --- a/api/app/domain/services/account_service.py +++ b/api/app/domain/services/account_service.py @@ -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 # ------------------------------------------------------------------ diff --git a/api/app/outbound/postgres/entities/user_entity.py b/api/app/outbound/postgres/entities/user_entity.py index dd09c58..766adc5 100644 --- a/api/app/outbound/postgres/entities/user_entity.py +++ b/api/app/outbound/postgres/entities/user_entity.py @@ -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( diff --git a/api/app/outbound/postgres/repositories/user_repository.py b/api/app/outbound/postgres/repositories/user_repository.py index cf17b15..b085557 100644 --- a/api/app/outbound/postgres/repositories/user_repository.py +++ b/api/app/outbound/postgres/repositories/user_repository.py @@ -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() diff --git a/api/app/routers/api/account.py b/api/app/routers/api/account.py index 52cb6c1..faafe82 100644 --- a/api/app/routers/api/account.py +++ b/api/app/routers/api/account.py @@ -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, diff --git a/api/app/routers/bff/account.py b/api/app/routers/bff/account.py index 300daeb..3273c89 100644 --- a/api/app/routers/bff/account.py +++ b/api/app/routers/bff/account.py @@ -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),