feat: [backend] Create BFF for adventure

This commit is contained in:
wilson 2026-05-04 11:45:41 +01:00
parent 48bbcac9a6
commit 85699fb9e5
6 changed files with 317 additions and 4 deletions

View file

@ -0,0 +1,138 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from ...auth import verify_token
from ...config import settings
from ...outbound.postgres.database import get_db
from ...outbound.postgres.repositories.adventure_repository import (
PostgresAdventureEntryAudioRepository,
PostgresAdventureEntryChoiceRepository,
PostgresAdventureEntryRepository,
PostgresAdventureEntryTranslationRepository,
PostgresAdventureRepository,
)
router = APIRouter(prefix="/adventure", tags=["bff", "adventures"])
class AdventureChoiceItem(BaseModel):
id: str
index: int
label: str
text: str
class AdventureEntryItem(BaseModel):
id: str
adventure_id: str
generated_from_choice_id: str | None
status: str
entry_index: int
story_text: str | None
translation: str | None
audio_url: str | None
created_at: str
class AdventureDetailResponse(BaseModel):
id: str
user_id: str
status: str
language: str
source_language: str
competencies: list[str]
max_entry_count: int
title: str
description: str | None
genres: list[str]
setting: list[str]
vibes: list[str]
protagonist: list[str]
created_at: str
entries: list[AdventureEntryItem]
current_entry_choices: list[AdventureChoiceItem]
def _audio_url(key: str | None) -> str | None:
if key is None:
return None
return f"{settings.api_base_url}/media/{key}"
@router.get("/{adventure_id}", response_model=AdventureDetailResponse, status_code=200)
async def get_adventure(
adventure_id: str,
db: AsyncSession = Depends(get_db),
token_data: dict = Depends(verify_token),
) -> AdventureDetailResponse:
user_id = uuid.UUID(token_data["sub"])
try:
adv_id = uuid.UUID(adventure_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid adventure_id")
adventure = await PostgresAdventureRepository(db).get_by_id(adv_id)
if adventure is None or adventure.user_id != str(user_id):
raise HTTPException(status_code=404, detail="Adventure not found")
entries = await PostgresAdventureEntryRepository(db).list_for_adventure(adv_id)
translation_repo = PostgresAdventureEntryTranslationRepository(db)
audio_repo = PostgresAdventureEntryAudioRepository(db)
entry_items = []
for entry in entries:
eid = uuid.UUID(entry.id)
translation = await translation_repo.get_for_entry(
entry_id=eid,
component_type="story_text",
target_language=adventure.source_language,
)
audio = await audio_repo.get_for_entry(entry_id=eid, component_type="story_text")
entry_items.append(
AdventureEntryItem(
id=entry.id,
adventure_id=entry.adventure_id,
generated_from_choice_id=entry.generated_from_choice_id,
status=entry.status,
entry_index=entry.entry_index,
story_text=entry.story_text,
translation=translation.translated_text if translation else None,
audio_url=_audio_url(audio.file_name if audio else None),
created_at=entry.created_at.isoformat(),
)
)
# Choices for the most recent entry, if it's complete (entries are ordered entry_index asc)
current_entry_choices: list[AdventureChoiceItem] = []
if entries and entries[-1].status == "complete":
choices = await PostgresAdventureEntryChoiceRepository(db).list_for_entry(
uuid.UUID(entries[-1].id)
)
current_entry_choices = [
AdventureChoiceItem(id=c.id, index=c.index, label=c.label, text=c.text)
for c in choices
]
return AdventureDetailResponse(
id=adventure.id,
user_id=adventure.user_id,
status=adventure.status,
language=adventure.language,
source_language=adventure.source_language,
competencies=adventure.competencies,
max_entry_count=adventure.max_entry_count,
title=adventure.title,
description=adventure.description,
genres=adventure.genres,
setting=adventure.setting,
vibes=adventure.vibes,
protagonist=adventure.protagonist,
created_at=adventure.created_at.isoformat(),
entries=entry_items,
current_entry_choices=current_entry_choices,
)

View file

@ -1,4 +1,5 @@
from .account import router as account_router from .account import router as account_router
from .adventure import router as adventure_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
from .packs import router as packs_router from .packs import router as packs_router
@ -8,6 +9,7 @@ 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(account_router)
bff_router.include_router(adventure_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)
bff_router.include_router(packs_router) bff_router.include_router(packs_router)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -192,6 +192,140 @@ export type AddWordRequest = {
source_article_id?: string | null; source_article_id?: string | null;
}; };
/**
* AdventureChoiceItem
*/
export type AdventureChoiceItem = {
/**
* Id
*/
id: string;
/**
* Index
*/
index: number;
/**
* Label
*/
label: string;
/**
* Text
*/
text: string;
};
/**
* AdventureDetailResponse
*/
export type AdventureDetailResponse = {
/**
* Id
*/
id: string;
/**
* User Id
*/
user_id: string;
/**
* Status
*/
status: string;
/**
* Language
*/
language: string;
/**
* Source Language
*/
source_language: string;
/**
* Competencies
*/
competencies: Array<string>;
/**
* Max Entry Count
*/
max_entry_count: number;
/**
* Title
*/
title: string;
/**
* Description
*/
description: string | null;
/**
* Genres
*/
genres: Array<string>;
/**
* Setting
*/
setting: Array<string>;
/**
* Vibes
*/
vibes: Array<string>;
/**
* Protagonist
*/
protagonist: Array<string>;
/**
* Created At
*/
created_at: string;
/**
* Entries
*/
entries: Array<AdventureEntryItem>;
/**
* Current Entry Choices
*/
current_entry_choices: Array<AdventureChoiceItem>;
};
/**
* AdventureEntryItem
*/
export type AdventureEntryItem = {
/**
* Id
*/
id: string;
/**
* Adventure Id
*/
adventure_id: string;
/**
* Generated From Choice Id
*/
generated_from_choice_id: string | null;
/**
* Status
*/
status: string;
/**
* Entry Index
*/
entry_index: number;
/**
* Story Text
*/
story_text: string | null;
/**
* Translation
*/
translation: string | null;
/**
* Audio Url
*/
audio_url: string | null;
/**
* Created At
*/
created_at: string;
};
/** /**
* AdventureResponse * AdventureResponse
*/ */
@ -2893,6 +3027,36 @@ export type GetOnboardingBffAccountOnboardingGetResponses = {
export type GetOnboardingBffAccountOnboardingGetResponse = GetOnboardingBffAccountOnboardingGetResponses[keyof GetOnboardingBffAccountOnboardingGetResponses]; export type GetOnboardingBffAccountOnboardingGetResponse = GetOnboardingBffAccountOnboardingGetResponses[keyof GetOnboardingBffAccountOnboardingGetResponses];
export type GetAdventureBffAdventureAdventureIdGetData = {
body?: never;
path: {
/**
* Adventure Id
*/
adventure_id: string;
};
query?: never;
url: '/bff/adventure/{adventure_id}';
};
export type GetAdventureBffAdventureAdventureIdGetErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetAdventureBffAdventureAdventureIdGetError = GetAdventureBffAdventureAdventureIdGetErrors[keyof GetAdventureBffAdventureAdventureIdGetErrors];
export type GetAdventureBffAdventureAdventureIdGetResponses = {
/**
* Successful Response
*/
200: AdventureDetailResponse;
};
export type GetAdventureBffAdventureAdventureIdGetResponse = GetAdventureBffAdventureAdventureIdGetResponses[keyof GetAdventureBffAdventureAdventureIdGetResponses];
export type ListArticlesBffArticlesGetData = { export type ListArticlesBffArticlesGetData = {
body?: never; body?: never;
path?: never; path?: never;