"""Current-user profile, preference, and voice-preset routes.""" from __future__ import annotations from fastapi import APIRouter, HTTPException, status from pydantic import BaseModel, Field from ..auth_sessions import DEFAULT_AFFILIATION, get_managed_user, update_managed_user from ..db import get_pool from ..deps import CurrentPrincipal from ..runtime_policy import require_runtime_fallback_allowed from ..services.voice import PRESET_RATE, PRESET_TO_OPENAI_VOICE router = APIRouter(prefix="/users", tags=["users"]) class UserProfileResponse(BaseModel): user_id: str email: str display_name: str role: str cohort_ids: list[str] affiliation: str class UserProfilePatch(BaseModel): display_name: str | None = Field(default=None, min_length=1, max_length=80) affiliation: str | None = Field(default=None, max_length=120) class NotificationPreferences(BaseModel): session_done: bool = True safety_signal: bool = True learner_progress: bool = False product_news: bool = False class UserPreferencesResponse(BaseModel): theme: str = "system" voice_preset_id: str = "soft-young-fem" voice_rate: float = 1.0 notifications: NotificationPreferences = Field(default_factory=NotificationPreferences) class UserPreferencesPatch(BaseModel): theme: str | None = None voice_preset_id: str | None = None voice_rate: float | None = Field(default=None, ge=0.8, le=1.2) notifications: NotificationPreferences | None = None class VoicePresetResponse(BaseModel): id: str voice_id: str name: str desc: str persona_hint: str _preferences: dict[str, UserPreferencesResponse] = {} VOICE_PRESET_META = { "soft-young-fem": { "name": "서린", "desc": "부드럽고 낮은 긴장감", "persona_hint": "청소년 내담자", }, "calm-adult-male": { "name": "민재", "desc": "차분하고 안정적인 성인 남성", "persona_hint": "성인 남성", }, "warm-adult-fem": { "name": "지영", "desc": "따뜻하지만 지친 성인 여성", "persona_hint": "성인 여성", }, "neutral": { "name": "기본", "desc": "중립적인 기본 음성", "persona_hint": "범용", }, } def _voice_preset_ids() -> set[str]: return set(PRESET_TO_OPENAI_VOICE.keys()) def _normalize_voice_preset(value: str | None) -> str: if value in _voice_preset_ids(): return str(value) return "soft-young-fem" def _assert_voice_preset(value: str | None) -> None: if value is None: return if value not in _voice_preset_ids(): raise HTTPException( status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"unsupported voice preset {value}", ) def _voice_presets() -> list[VoicePresetResponse]: presets: list[VoicePresetResponse] = [] for preset_id in PRESET_TO_OPENAI_VOICE.keys(): meta = VOICE_PRESET_META.get( preset_id, { "name": preset_id, "desc": f"rate {PRESET_RATE.get(preset_id, 1.0):.2f}", "persona_hint": "사용자 지정", }, ) presets.append( VoicePresetResponse( id=preset_id, voice_id=preset_id, name=meta["name"], desc=meta["desc"], persona_hint=meta["persona_hint"], ) ) return presets def _preferences_from_row(row) -> UserPreferencesResponse: return UserPreferencesResponse( theme=row["theme"], voice_preset_id=_normalize_voice_preset(row["voice_preset_id"]), voice_rate=float(row["voice_rate"]), notifications=NotificationPreferences.model_validate(row["notifications"] or {}), ) async def _profile_for(principal: CurrentPrincipal) -> UserProfileResponse: managed = await get_managed_user(principal.user_id) return UserProfileResponse( user_id=principal.user_id, email=principal.email, display_name=( (managed.display_name if managed else "") or principal.display_name or principal.email ), role=(managed.role if managed else principal.role.value), cohort_ids=(managed.cohort_ids if managed else principal.cohort_ids), affiliation=(managed.affiliation if managed else DEFAULT_AFFILIATION), ) @router.get("/me", response_model=UserProfileResponse) async def get_me(principal: CurrentPrincipal) -> UserProfileResponse: return await _profile_for(principal) @router.patch("/me", response_model=UserProfileResponse) async def patch_me(body: UserProfilePatch, principal: CurrentPrincipal) -> UserProfileResponse: profile = await _profile_for(principal) await update_managed_user( principal.user_id, display_name=body.display_name if body.display_name is not None else profile.display_name, affiliation=body.affiliation if body.affiliation is not None else profile.affiliation, ) return await _profile_for(principal) @router.get("/me/preferences", response_model=UserPreferencesResponse) async def get_preferences(principal: CurrentPrincipal) -> UserPreferencesResponse: try: pool = get_pool() async with pool.acquire() as conn: await conn.execute( """ INSERT INTO app.user_preferences (user_id, notifications) VALUES ($1::uuid, $2::jsonb) ON CONFLICT (user_id) DO NOTHING """, principal.user_id, NotificationPreferences().model_dump(), ) row = await conn.fetchrow( """ SELECT theme, voice_preset_id, voice_rate, notifications FROM app.user_preferences WHERE user_id = $1::uuid """, principal.user_id, ) if row is not None: return _preferences_from_row(row) except Exception: require_runtime_fallback_allowed("user preferences") return _preferences.setdefault(principal.user_id, UserPreferencesResponse()) @router.patch("/me/preferences", response_model=UserPreferencesResponse) async def patch_preferences( body: UserPreferencesPatch, principal: CurrentPrincipal, ) -> UserPreferencesResponse: _assert_voice_preset(body.voice_preset_id) try: pool = get_pool() async with pool.acquire() as conn: await conn.execute( """ INSERT INTO app.user_preferences (user_id, notifications) VALUES ($1::uuid, $2::jsonb) ON CONFLICT (user_id) DO NOTHING """, principal.user_id, NotificationPreferences().model_dump(), ) current = await conn.fetchrow( """ SELECT theme, voice_preset_id, voice_rate, notifications FROM app.user_preferences WHERE user_id = $1::uuid """, principal.user_id, ) current_prefs = _preferences_from_row(current) next_prefs = UserPreferencesResponse( theme=body.theme if body.theme is not None else current_prefs.theme, voice_preset_id=( body.voice_preset_id if body.voice_preset_id is not None else current_prefs.voice_preset_id ), voice_rate=body.voice_rate if body.voice_rate is not None else current_prefs.voice_rate, notifications=( body.notifications if body.notifications is not None else current_prefs.notifications ), ) row = await conn.fetchrow( """ UPDATE app.user_preferences SET theme = $2, voice_preset_id = $3, voice_rate = $4, notifications = $5::jsonb, updated_at = now() WHERE user_id = $1::uuid RETURNING theme, voice_preset_id, voice_rate, notifications """, principal.user_id, next_prefs.theme, next_prefs.voice_preset_id, next_prefs.voice_rate, next_prefs.notifications.model_dump(), ) return _preferences_from_row(row) except HTTPException: raise except Exception: require_runtime_fallback_allowed("user preferences") current = _preferences.setdefault(principal.user_id, UserPreferencesResponse()) data = current.model_dump() if body.theme is not None: data["theme"] = body.theme if body.voice_preset_id is not None: data["voice_preset_id"] = body.voice_preset_id if body.voice_rate is not None: data["voice_rate"] = body.voice_rate if body.notifications is not None: data["notifications"] = body.notifications.model_dump() next_prefs = UserPreferencesResponse.model_validate(data) _preferences[principal.user_id] = next_prefs return next_prefs @router.get("/me/voice-presets", response_model=list[VoicePresetResponse]) async def get_voice_presets(principal: CurrentPrincipal) -> list[VoicePresetResponse]: return _voice_presets()