From 0281caef7cb36152cd34c404e69c63cdc60f60f4 Mon Sep 17 00:00:00 2001 From: wilson Date: Wed, 8 Apr 2026 20:50:26 +0100 Subject: [PATCH] feat: Endpoints to manage your account. --- api/app/domain/models/account.py | 14 ++ api/app/domain/services/account_service.py | 150 ++++++++++++++++++ .../learnable_language_repository.py | 20 +++ api/app/routers/api/account.py | 97 +++++++++++ api/app/routers/api/main.py | 2 + api/app/routers/auth.py | 23 +-- 6 files changed, 290 insertions(+), 16 deletions(-) create mode 100644 api/app/domain/models/account.py create mode 100644 api/app/domain/services/account_service.py create mode 100644 api/app/routers/api/account.py diff --git a/api/app/domain/models/account.py b/api/app/domain/models/account.py new file mode 100644 index 0000000..95811aa --- /dev/null +++ b/api/app/domain/models/account.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass, field +from datetime import datetime + +from .learnable_language import LearnableLanguage + + +@dataclass +class Account: + id: str + email: str + is_active: bool + is_email_verified: bool + created_at: datetime + 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 new file mode 100644 index 0000000..918ab04 --- /dev/null +++ b/api/app/domain/services/account_service.py @@ -0,0 +1,150 @@ +import uuid + +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 +from ...outbound.postgres.entities.user_entity import User as UserEntity +from ...outbound.postgres.repositories import 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). + + Usage:: + + service = AccountService(db) + + # Registration + account = await service.create_account("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 + await service.remove_learnable_language(user_id, lang.id) + """ + + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def create_account(self, email: str, password: str) -> Account: + """Create a new user account, hashing the plain-text password before storage. + + Raises ``ValueError`` if the email address is already registered, so the + caller does not need to catch SQLAlchemy exceptions directly. + + Usage:: + + try: + account = await service.create_account("alice@example.com", "s3cr3t") + except ValueError: + # email already taken + ... + """ + 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") + + return 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, + ) + + 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) + """ + # user_repository only exposes get_by_email; query by id directly + 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, + is_active=user.is_active, + is_email_verified=user.is_email_verified, + created_at=user.created_at, + learnable_languages=languages, + ) + + 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"], + ) + print(lang.id) # UUID of the learnable_language row + """ + 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") diff --git a/api/app/outbound/postgres/repositories/learnable_language_repository.py b/api/app/outbound/postgres/repositories/learnable_language_repository.py index 245783c..2e7a24b 100644 --- a/api/app/outbound/postgres/repositories/learnable_language_repository.py +++ b/api/app/outbound/postgres/repositories/learnable_language_repository.py @@ -7,6 +7,26 @@ from ..entities.learnable_language_entity import LearnableLanguageEntity from ....domain.models.learnable_language import LearnableLanguage +async def delete(db: AsyncSession, user_id: uuid.UUID, language_id: uuid.UUID) -> bool: + """Delete a learnable language row owned by ``user_id``. + + Returns ``True`` if a row was deleted, ``False`` if no matching row was found. + The ``user_id`` check prevents one user from deleting another's data. + """ + result = await db.execute( + select(LearnableLanguageEntity).where( + LearnableLanguageEntity.id == language_id, + LearnableLanguageEntity.user_id == user_id, + ) + ) + entity = result.scalar_one_or_none() + if entity is None: + return False + await db.delete(entity) + await db.commit() + return True + + def _to_model(entity: LearnableLanguageEntity) -> LearnableLanguage: return LearnableLanguage( id=str(entity.id), diff --git a/api/app/routers/api/account.py b/api/app/routers/api/account.py new file mode 100644 index 0000000..52cb6c1 --- /dev/null +++ b/api/app/routers/api/account.py @@ -0,0 +1,97 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, field_validator +from sqlalchemy.ext.asyncio import AsyncSession + +from ...auth import verify_token +from ...domain.services.account_service import AccountService +from ...languages import SUPPORTED_LANGUAGES, SUPPORTED_LEVELS +from ...outbound.postgres.database import get_db + +router = APIRouter(prefix="/account", tags=["account"]) + + +class AddLearnableLanguageRequest(BaseModel): + source_language: str + target_language: str + proficiencies: list[str] + + @field_validator("proficiencies") + @classmethod + def validate_proficiencies(cls, v: list[str]) -> list[str]: + if not (1 <= len(v) <= 2): + raise ValueError("proficiencies must contain 1 or 2 levels") + invalid = [p for p in v if p not in SUPPORTED_LEVELS] + if invalid: + raise ValueError(f"Invalid proficiency levels: {invalid}. Supported: {sorted(SUPPORTED_LEVELS)}") + return v + + +class LearnableLanguageResponse(BaseModel): + id: str + source_language: str + target_language: str + proficiencies: list[str] + + +@router.post( + "/learnable-languages", + response_model=LearnableLanguageResponse, + status_code=status.HTTP_201_CREATED, +) +async def add_learnable_language( + body: AddLearnableLanguageRequest, + db: AsyncSession = Depends(get_db), + token_data: dict = Depends(verify_token), +) -> LearnableLanguageResponse: + if body.source_language not in SUPPORTED_LANGUAGES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unsupported source language '{body.source_language}'. Supported: {list(SUPPORTED_LANGUAGES)}", + ) + if body.target_language not in SUPPORTED_LANGUAGES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unsupported target language '{body.target_language}'. Supported: {list(SUPPORTED_LANGUAGES)}", + ) + if body.source_language == body.target_language: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="source_language and target_language must differ", + ) + + user_id = uuid.UUID(token_data["sub"]) + lang = await AccountService(db).add_learnable_language( + user_id=user_id, + source_language=body.source_language, + target_language=body.target_language, + proficiencies=body.proficiencies, + ) + return LearnableLanguageResponse( + id=lang.id, + source_language=lang.source_language, + target_language=lang.target_language, + proficiencies=lang.proficiencies, + ) + + +@router.delete( + "/learnable-languages/{language_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def remove_learnable_language( + language_id: str, + db: AsyncSession = Depends(get_db), + token_data: dict = Depends(verify_token), +) -> None: + try: + lid = uuid.UUID(language_id) + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid language_id") + + user_id = uuid.UUID(token_data["sub"]) + try: + await AccountService(db).remove_learnable_language(user_id=user_id, language_id=lid) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) diff --git a/api/app/routers/api/main.py b/api/app/routers/api/main.py index f671211..85df4b3 100644 --- a/api/app/routers/api/main.py +++ b/api/app/routers/api/main.py @@ -1,3 +1,4 @@ +from .account import router as account_router from .pos import router as pos_router from .translate import router as translate_router from .generation import router as generation_router @@ -9,6 +10,7 @@ from fastapi import APIRouter api_router = APIRouter(prefix="/api", tags=["api"]) +api_router.include_router(account_router) api_router.include_router(pos_router) api_router.include_router(translate_router) api_router.include_router(generation_router) diff --git a/api/app/routers/auth.py b/api/app/routers/auth.py index f595869..5de7ec4 100644 --- a/api/app/routers/auth.py +++ b/api/app/routers/auth.py @@ -1,9 +1,9 @@ from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, EmailStr -from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession -from ..auth import create_access_token, hash_password, verify_password +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 @@ -27,24 +27,15 @@ class TokenResponse(BaseModel): @router.post("/register", status_code=status.HTTP_201_CREATED) async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)): - try: - user = await user_repository.create( - db, - email=body.email, - hashed_password=hash_password(body.password), - ) - except IntegrityError: - await db.rollback() - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="Email already registered", - ) - # 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": str(user.id), "email": user.email} + return {"id": account.id, "email": account.email} @router.post("/login", response_model=TokenResponse)