feat: [api] Create the Register and Onboarding endpoints (API and BFF)
This commit is contained in:
parent
36d09b2304
commit
88c355053f
11 changed files with 395 additions and 99 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
@ -6,54 +7,69 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..models.account import Account
|
from ..models.account import Account
|
||||||
from ..models.learnable_language import LearnableLanguage
|
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.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:
|
class AccountService:
|
||||||
"""Handles account-level operations: registration, profile retrieval, and managing
|
"""Handles account-level operations: registration, authentication, email
|
||||||
the set of languages a user is learning.
|
verification, 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::
|
Usage::
|
||||||
|
|
||||||
service = AccountService(db)
|
service = AccountService(db)
|
||||||
|
|
||||||
# Registration
|
# Registration — returns the new account and the verification link
|
||||||
account = await service.create_account("alice@example.com", "s3cr3t")
|
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
|
# Profile retrieval
|
||||||
account = await service.get_account(user_id)
|
account = await service.get_account(user_id)
|
||||||
print(account.learnable_languages) # [LearnableLanguage(...), ...]
|
|
||||||
|
|
||||||
# Add French (B1) to the account
|
# Language management
|
||||||
lang = await service.add_learnable_language(
|
lang = await service.add_learnable_language(user_id, "en", "fr", ["B1"])
|
||||||
user_id, source_language="en", target_language="fr", proficiencies=["B1"]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Remove it again
|
|
||||||
await service.remove_learnable_language(user_id, lang.id)
|
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
|
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
|
async def register_new_account(self, email: str, password: str) -> tuple[Account, str]:
|
||||||
caller does not need to catch SQLAlchemy exceptions directly.
|
"""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::
|
Usage::
|
||||||
|
|
||||||
try:
|
account, link = await service.register_new_account("alice@example.com", "s3cr3t")
|
||||||
account = await service.create_account("alice@example.com", "s3cr3t")
|
|
||||||
except ValueError:
|
|
||||||
# email already taken
|
|
||||||
...
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
user = await user_repository.create(
|
user = await user_repository.create(
|
||||||
|
|
@ -65,6 +81,59 @@ class AccountService:
|
||||||
await self.db.rollback()
|
await self.db.rollback()
|
||||||
raise ValueError("Email already registered")
|
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(
|
return Account(
|
||||||
id=str(user.id),
|
id=str(user.id),
|
||||||
email=user.email,
|
email=user.email,
|
||||||
|
|
@ -73,6 +142,36 @@ class AccountService:
|
||||||
created_at=user.created_at,
|
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:
|
async def get_account(self, user_id: uuid.UUID) -> Account:
|
||||||
"""Retrieve a user's account profile including all their learnable languages.
|
"""Retrieve a user's account profile including all their learnable languages.
|
||||||
|
|
||||||
|
|
@ -84,7 +183,6 @@ class AccountService:
|
||||||
for lang in account.learnable_languages:
|
for lang in account.learnable_languages:
|
||||||
print(lang.target_language, lang.proficiencies)
|
print(lang.target_language, lang.proficiencies)
|
||||||
"""
|
"""
|
||||||
# user_repository only exposes get_by_email; query by id directly
|
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(UserEntity).where(UserEntity.id == user_id)
|
select(UserEntity).where(UserEntity.id == user_id)
|
||||||
)
|
)
|
||||||
|
|
@ -103,6 +201,10 @@ class AccountService:
|
||||||
learnable_languages=languages,
|
learnable_languages=languages,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Language management
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def add_learnable_language(
|
async def add_learnable_language(
|
||||||
self,
|
self,
|
||||||
user_id: uuid.UUID,
|
user_id: uuid.UUID,
|
||||||
|
|
@ -121,7 +223,6 @@ class AccountService:
|
||||||
target_language="fr",
|
target_language="fr",
|
||||||
proficiencies=["B1", "B2"],
|
proficiencies=["B1", "B2"],
|
||||||
)
|
)
|
||||||
print(lang.id) # UUID of the learnable_language row
|
|
||||||
"""
|
"""
|
||||||
return await learnable_language_repository.upsert(
|
return await learnable_language_repository.upsert(
|
||||||
self.db,
|
self.db,
|
||||||
|
|
@ -136,8 +237,8 @@ class AccountService:
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Remove a learnable language from the user's account by its row ID.
|
"""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
|
Raises ``ValueError`` if the language entry does not exist or does not
|
||||||
to ``user_id``.
|
belong to ``user_id``.
|
||||||
|
|
||||||
Usage::
|
Usage::
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,3 +7,15 @@ SUPPORTED_LANGUAGES: dict[str, str] = {
|
||||||
}
|
}
|
||||||
|
|
||||||
SUPPORTED_LEVELS = {"A1", "A2", "B1", "B2", "C1", "C2"}
|
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",
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ from .routers.api import generation, pos
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from .routers.api import jobs
|
from .routers.api import jobs
|
||||||
from .routers import auth as auth_router
|
|
||||||
from .routers import media as media_router
|
from .routers import media as media_router
|
||||||
from .routers.api.main import api_router
|
from .routers.api.main import api_router
|
||||||
from .routers.bff.main import bff_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(api_router)
|
||||||
app.include_router(bff_router)
|
app.include_router(bff_router)
|
||||||
app.include_router(auth_router.router)
|
|
||||||
app.include_router(media_router.router)
|
app.include_router(media_router.router)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
|
@ -17,8 +17,7 @@ class User(Base):
|
||||||
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
|
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
|
||||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
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=False)
|
||||||
is_email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
default=lambda: datetime.now(timezone.utc),
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
88
api/app/routers/api/auth.py
Normal file
88
api/app/routers/api/auth.py
Normal 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}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from .account import router as account_router
|
from .account import router as account_router
|
||||||
|
from .auth import router as auth_router
|
||||||
from .flashcards import router as flashcards_router
|
from .flashcards import router as flashcards_router
|
||||||
from .pos import router as pos_router
|
from .pos import router as pos_router
|
||||||
from .translate import router as translate_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 = APIRouter(prefix="/api", tags=["api"])
|
||||||
|
|
||||||
|
api_router.include_router(auth_router)
|
||||||
api_router.include_router(account_router)
|
api_router.include_router(account_router)
|
||||||
api_router.include_router(flashcards_router)
|
api_router.include_router(flashcards_router)
|
||||||
api_router.include_router(pos_router)
|
api_router.include_router(pos_router)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
82
api/app/routers/bff/account.py
Normal file
82
api/app/routers/bff/account.py
Normal 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(),
|
||||||
|
)
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from .account import router as account_router
|
||||||
from .articles import router as article_router
|
from .articles import router as article_router
|
||||||
from .user_profile import router as user_profile_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 = APIRouter(prefix="/bff", tags=["bff"])
|
||||||
|
|
||||||
|
bff_router.include_router(account_router)
|
||||||
bff_router.include_router(article_router)
|
bff_router.include_router(article_router)
|
||||||
bff_router.include_router(user_profile_router)
|
bff_router.include_router(user_profile_router)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue