vignette/apps/api/app/routes/users.py
2026-06-28 12:18:20 +09:00

697 lines
30 KiB
Python

"""Current-user profile, preference, and voice-preset routes."""
from __future__ import annotations
import secrets
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, File, HTTPException, UploadFile, status
from pydantic import BaseModel, Field
from ..auth_types import RoleName
from ..auth_sessions import (
DEFAULT_AFFILIATION,
ManagedUserPatch,
get_managed_user,
record_user_consent,
update_managed_user,
)
from ..config import settings
from ..db import acquire, get_pool
from ..deps import CurrentPrincipal, Role
from ..runtime_policy import require_runtime_fallback_allowed
from ..services.voice import PRESET_RATE, PRESET_TO_OPENAI_VOICE
router = APIRouter(prefix="/users", tags=["users"])
TERMS_VERSION = "terms-draft-2026-06-27"
PRIVACY_VERSION = "privacy-draft-2026-06-27"
AVATAR_MAX_BYTES = 3 * 1024 * 1024
AVATAR_CONTENT_TYPES = {
"image/png": ("png", b"\x89PNG\r\n\x1a\n"),
"image/jpeg": ("jpg", b"\xff\xd8\xff"),
"image/webp": ("webp", b"RIFF"),
}
TERMS_BODY = """Vignette 서비스 이용약관 초안
1. 목적
본 약관은 Vignette가 제공하는 AI 심리상담 시뮬레이션 훈련 플랫폼의 이용 조건, 권리와 의무, 책임 범위를 정한다. Vignette는 상담 수련생의 교육, 실습, 교수자 피드백, 운영 품질 관리를 위한 비치료 교육 도구이며 실제 진단, 치료, 응급 위기 개입을 대체하지 않는다.
2. 계정과 이용 자격
서비스는 한신대학교 산학협력 교육 과정, 승인된 연구/수업, 운영자가 허용한 기관 계정에 한해 제공된다. 사용자는 본인 계정으로만 로그인해야 하며 타인의 Google 계정, 학교 계정, 세션 쿠키를 빌리거나 공유해서는 안 된다. 교수자와 관리자는 운영자가 별도로 지정한 이메일 allowlist 또는 관리자 사용자 관리 절차를 통해 권한을 부여받는다.
3. 역할별 이용 범위
학습자는 AI 내담자와의 모의 회기, 회기 종료 후 피드백, 사례개념화 워크시트 작성 기능을 사용할 수 있다. 교수자는 담당 코호트 또는 승인 범위 안에서 학습자의 회기 요약, 안전 알림, 성장 지표, 제출물을 검토할 수 있다. 관리자는 사용자 권한, 시스템 상태, 비용, 페르소나 승인, 감사 기록을 관리한다.
4. AI 시뮬레이션의 성격
AI 내담자는 교육용 페르소나를 연기한다. AI 응답은 상담 훈련을 위한 시뮬레이션 산출물이며 의료, 임상, 법률, 행정 판단으로 사용해서는 안 된다. 사용자는 실제 위기 상황, 자해·자살 위험, 폭력 위험, 학대 의심 등 즉시 개입이 필요한 상황에서는 119, 112, 109 또는 기관의 위기 대응 절차를 우선해야 한다.
5. 사용자 의무
사용자는 허위 정보 입력, 타인의 개인정보 입력, 비인가 접근, 취약점 탐색, 데이터 무단 반출, 모델 프롬프트 탈취, 자동화된 대량 요청, 실존 인물 사칭, 교육 목적을 벗어난 민감 정보 입력을 해서는 안 된다. 상담 실습 중 실제 제3자의 이름, 연락처, 주소, 주민등록번호, 진료 정보 등 불필요한 개인정보는 입력하지 않는 것을 원칙으로 한다.
6. 콘텐츠와 기록
학습자가 입력한 발화, AI 내담자 응답, 회기 메타데이터, 평가 결과, 사례개념화 워크시트는 교육 운영, 피드백 제공, 품질 개선, 안전 관리, 연구 검증을 위해 저장될 수 있다. 원문은 접근 권한과 RLS 정책에 따라 제한되고, 외부 AI 처리 경로에는 가능한 범위에서 마스킹된 텍스트를 사용한다.
7. 서비스 변경과 중단
운영자는 교육 일정, 보안, 장애, 비용, 외부 AI 제공자 상태, 학교 또는 연구기관 정책에 따라 기능을 변경하거나 일시 중단할 수 있다. 운영자는 중대한 변경이 있을 때 가능한 범위에서 사전에 안내한다.
8. 권한 회수와 이용 제한
운영자는 계정 오용, 보안 위험, 허위 정보, 교육 목적 외 사용, 법령 또는 기관 정책 위반이 확인되면 이용을 제한하거나 세션을 무효화할 수 있다. 교수자와 관리자의 권한은 직무 변경, 코호트 변경, 산학협력 범위 변경, 퇴직 또는 운영자 결정에 따라 조정될 수 있다.
9. 책임 제한
서비스는 교육과 연구 목적의 보조 도구다. AI 응답의 완전성, 임상적 정확성, 특정 학습 성과를 보장하지 않는다. 다만 운영자는 개인정보 보호, 접근통제, 감사, 안전 게이트 등 합리적인 보호조치를 유지하기 위해 노력한다.
10. 준거와 개정
본 약관은 대한민국 법령과 한신대학교 및 산학협력 운영 기준을 따른다. 본 문서는 운영 전 초안이며, 최종 약관은 윤찬, 한신대학교 담당자, 법무/개인정보 검토 결과에 따라 수정될 수 있다.
"""
PRIVACY_BODY = """Vignette 개인정보 수집·이용 및 처리방침 초안
1. 처리 목적
Vignette는 AI 심리상담 시뮬레이션 교육 운영, 사용자 식별, 역할별 권한 관리, 교수자 피드백, 회기 기록 보존, 사례개념화 과제 관리, 안전 이벤트 대응, 서비스 품질 개선, 연구·평가 지표 산출, 법령과 기관 정책 준수를 위해 개인정보를 처리한다.
2. 수집 항목
필수 항목은 로그인 이메일, 이름, 닉네임, 자기소개, 소속, 학과, 학년 또는 직위, 연락처, 주소 또는 우편물 수령지, 역할, 코호트, 로그인/접속 기록, 동의 이력, 회기 발화와 AI 응답, 회기 메타데이터, 평가 및 피드백 결과다. 선택 항목으로 사용자가 업로드한 프로필 아바타 이미지와 그 저장 URL을 처리할 수 있다. 이메일은 Google 또는 학교 계정 로그인으로 확인되므로 별도 입력을 받지 않는다.
3. 민감한 교육 데이터
상담 실습 과정에서 심리 상태, 위기 표현, 사례개념화 내용, 음성 입력 메타데이터, 교수자 코멘트가 생성될 수 있다. 이는 실제 치료기록이 아니라 교육용 시뮬레이션 기록이지만, 재식별 위험과 민감성을 고려해 접근권한을 제한하고 감사 로그를 남긴다.
4. 보유와 이용 기간
개인정보와 학습 기록은 산학협력 교육·연구 운영, 성과 검증, 감사, 분쟁 대응에 필요한 기간 동안 보유한다. 구체적인 보유 기간, 파기 주기, 연구 데이터 익명화 기준은 기관 검토 후 확정한다. 사용 중지 또는 권한 회수 후에도 법령상 의무, 연구 검증, 감사 목적상 필요한 최소 기록은 별도 기간 동안 보관될 수 있다.
5. 제3자 제공과 위탁
서비스 운영 과정에서 Google 로그인, 외부 AI 모델, 음성 처리, 인프라 제공자 등 외부 서비스가 사용될 수 있다. 외부 AI 경로에는 원칙적으로 마스킹된 텍스트와 필요한 최소 메타데이터만 전달하고, prompt/completion 본문을 비용 감사 로그에 저장하지 않는다. 실제 위탁·제3자 제공 목록과 국외 이전 여부는 배포 전 별도 고지로 확정한다.
6. 안전성 확보조치
운영자는 서버 측 HttpOnly 쿠키 세션, 역할 기반 접근통제, 코호트 범위 제한, DB RLS, 감사 로그, 외부 AI 호출 메타데이터 기록, 개인정보 마스킹, 권한 회수, 비활성 계정 차단, 최소 권한 원칙을 적용한다. 운영 환경에서는 시크릿을 코드에 저장하지 않고, 접근 권한과 로그를 분리 관리한다.
7. 정보주체 권리
사용자는 본인의 개인정보 열람, 정정, 처리정지, 삭제 요청을 할 수 있다. 다만 교육 평가, 연구 검증, 법령상 보존 의무, 다른 사용자의 권리 보호, 감사 목적에 필요한 기록은 즉시 삭제가 제한될 수 있다. 요청 창구와 처리 절차는 운영자 및 한신대학교 담당 부서 확정 후 고지한다.
8. 미성년자와 보호자 동의
서비스가 미성년 학습자 또는 미성년 사례 자료를 다루는 경우 보호자 동의, 기관 승인, IRB 또는 이에 준하는 검토가 필요한지 별도로 확인한다. 현재 문안은 기술 구현용 초안이며 실제 운영 전 법무·개인정보·임상팀 검토가 필요하다.
9. 국내법 준수
개인정보 처리는 개인정보 보호법, 동법 시행령, 개인정보 처리방침 작성지침, 개인정보의 안전성 확보조치 기준 등 대한민국 개인정보보호 법령과 관련 고시를 기준으로 운영한다. 법령 개정 또는 기관 정책 변경 시 처리방침을 개정할 수 있다.
10. 시행과 개정
본 방침은 2026년 6월 27일 개발 초안이다. 최종 시행일, 개인정보 보호책임자, 문의처, 보유 기간, 위탁·제3자 제공 내역은 운영 전 확정해 고지한다.
"""
class UserProfileResponse(BaseModel):
user_id: str
email: str
display_name: str
role: RoleName
cohort_ids: list[str]
affiliation: str
legal_name: str = ""
department: str = ""
grade_level: str = ""
phone: str = ""
contact_address: str = ""
nickname: str = ""
self_introduction: str = ""
avatar_url: str = ""
onboarding_completed_at: float | None = None
terms_agreed_at: float | None = None
privacy_agreed_at: float | None = None
terms_version: str = ""
privacy_version: str = ""
onboarding_required: bool = True
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)
legal_name: str | None = Field(default=None, min_length=1, max_length=80)
department: str | None = Field(default=None, max_length=120)
grade_level: str | None = Field(default=None, max_length=40)
phone: str | None = Field(default=None, max_length=30)
contact_address: str | None = Field(default=None, max_length=300)
nickname: str | None = Field(default=None, min_length=1, max_length=40)
self_introduction: str | None = Field(default=None, max_length=600)
avatar_url: str | None = Field(default=None, max_length=500)
class OnboardingRequest(BaseModel):
legal_name: str = Field(..., min_length=1, max_length=80)
affiliation: str = Field(..., min_length=1, max_length=120)
department: str = Field(..., min_length=1, max_length=120)
grade_level: str = Field(..., min_length=1, max_length=40)
phone: str = Field(..., min_length=1, max_length=30)
contact_address: str = Field(..., min_length=1, max_length=300)
nickname: str = Field(..., min_length=1, max_length=40)
self_introduction: str = Field(..., min_length=1, max_length=600)
avatar_url: str = Field(default="", max_length=500)
terms_accepted: bool
privacy_accepted: bool
class AvatarUploadResponse(BaseModel):
avatar_url: str
content_type: str
size_bytes: int
class LegalDocument(BaseModel):
kind: Literal["terms", "privacy"]
version: str
title: str
body: str
status: Literal["draft"] = "draft"
class LegalDocumentsResponse(BaseModel):
terms: LegalDocument
privacy: LegalDocument
source_note: str
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
TicketCategory = Literal[
"account_access",
"session_review",
"voice_browser",
"content_scenario",
"safety",
"other",
]
TicketPriority = Literal["low", "normal", "high", "urgent"]
class UserSupportTicketRequest(BaseModel):
category: TicketCategory = "other"
priority: TicketPriority = "normal"
subject: str = Field(..., min_length=2, max_length=160)
body: str = Field(..., min_length=2, max_length=4000)
source_path: str = Field(default="", max_length=500)
class UserSupportTicketResponse(BaseModel):
ticket_id: str
status: Literal["open"]
category: TicketCategory
priority: TicketPriority
subject: str
created_at: float
_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 {}),
)
def _onboarding_required(managed) -> bool:
return not (
managed
and managed.profile_completed_at is not None
and managed.terms_agreed_at is not None
and managed.privacy_agreed_at is not None
and bool(managed.nickname.strip())
and bool(managed.self_introduction.strip())
)
def _upload_root() -> Path:
root = Path(settings.user_upload_dir)
if not root.is_absolute():
root = Path.cwd() / root
avatar_root = root / "profile-avatars"
avatar_root.mkdir(parents=True, exist_ok=True)
return avatar_root
def _validated_avatar_extension(content_type: str, content: bytes) -> str:
normalized = content_type.split(";", 1)[0].strip().lower()
if normalized not in AVATAR_CONTENT_TYPES:
raise HTTPException(
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail="unsupported_avatar_type",
)
ext, magic = AVATAR_CONTENT_TYPES[normalized]
if normalized == "image/webp":
if not (content.startswith(magic) and content[8:12] == b"WEBP"):
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="invalid_avatar_file")
elif not content.startswith(magic):
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="invalid_avatar_file")
return ext
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),
legal_name=(managed.legal_name if managed else ""),
department=(managed.department if managed else ""),
grade_level=(managed.grade_level if managed else ""),
phone=(managed.phone if managed else ""),
contact_address=(managed.contact_address if managed else ""),
nickname=(managed.nickname if managed else ""),
self_introduction=(managed.self_introduction if managed else ""),
avatar_url=(managed.avatar_url if managed else ""),
onboarding_completed_at=(managed.profile_completed_at if managed else None),
terms_agreed_at=(managed.terms_agreed_at if managed else None),
privacy_agreed_at=(managed.privacy_agreed_at if managed else None),
terms_version=(managed.terms_version if managed else ""),
privacy_version=(managed.privacy_version if managed else ""),
onboarding_required=_onboarding_required(managed),
)
@router.get("/legal-docs", response_model=LegalDocumentsResponse)
async def get_legal_documents(principal: CurrentPrincipal) -> LegalDocumentsResponse:
return LegalDocumentsResponse(
terms=LegalDocument(
kind="terms",
version=TERMS_VERSION,
title="Vignette 서비스 이용약관 초안",
body=TERMS_BODY,
),
privacy=LegalDocument(
kind="privacy",
version=PRIVACY_VERSION,
title="Vignette 개인정보 수집·이용 및 처리방침 초안",
body=PRIVACY_BODY,
),
source_note=(
"법무 검토 전 개발 초안입니다. 개인정보 보호법 제30조, 개인정보 처리방침 작성지침, "
"개인정보의 안전성 확보조치 기준, 약관규제법 취지를 반영했습니다."
),
)
@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)
updated = await update_managed_user(
principal.user_id,
ManagedUserPatch(
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,
legal_name=body.legal_name if body.legal_name is not None else profile.legal_name,
department=body.department if body.department is not None else profile.department,
grade_level=body.grade_level if body.grade_level is not None else profile.grade_level,
phone=body.phone if body.phone is not None else profile.phone,
contact_address=(
body.contact_address
if body.contact_address is not None
else profile.contact_address
),
nickname=body.nickname if body.nickname is not None else profile.nickname,
self_introduction=(
body.self_introduction
if body.self_introduction is not None
else profile.self_introduction
),
avatar_url=body.avatar_url if body.avatar_url is not None else profile.avatar_url,
),
)
if updated is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
return await _profile_for(principal)
@router.post("/me/avatar", response_model=AvatarUploadResponse)
async def upload_my_avatar(
principal: CurrentPrincipal,
file: UploadFile = File(...),
) -> AvatarUploadResponse:
content_type = (file.content_type or "").strip().lower()
content = await file.read(AVATAR_MAX_BYTES + 1)
await file.close()
if not content:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="empty_avatar_file")
if len(content) > AVATAR_MAX_BYTES:
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="avatar_too_large")
ext = _validated_avatar_extension(content_type, content)
root = _upload_root()
for existing in root.glob(f"{principal.user_id}-*.png"):
existing.unlink(missing_ok=True)
for existing in root.glob(f"{principal.user_id}-*.jpg"):
existing.unlink(missing_ok=True)
for existing in root.glob(f"{principal.user_id}-*.webp"):
existing.unlink(missing_ok=True)
filename = f"{principal.user_id}-{secrets.token_urlsafe(10)}.{ext}"
target = root / filename
target.write_bytes(content)
avatar_url = f"/uploads/profile-avatars/{filename}"
return AvatarUploadResponse(
avatar_url=avatar_url,
content_type=content_type.split(";", 1)[0],
size_bytes=len(content),
)
@router.post("/me/onboarding", response_model=UserProfileResponse)
async def complete_onboarding(
body: OnboardingRequest,
principal: CurrentPrincipal,
) -> UserProfileResponse:
if not body.terms_accepted:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="terms_not_accepted")
if not body.privacy_accepted:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="privacy_not_accepted")
display_name = body.nickname.strip()
updated = await update_managed_user(
principal.user_id,
ManagedUserPatch(
display_name=display_name,
affiliation=body.affiliation,
legal_name=body.legal_name,
department=body.department,
grade_level=body.grade_level,
phone=body.phone,
contact_address=body.contact_address,
nickname=body.nickname,
self_introduction=body.self_introduction,
avatar_url=body.avatar_url,
complete_onboarding=True,
terms_version=TERMS_VERSION,
privacy_version=PRIVACY_VERSION,
),
)
if updated is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
principal.display_name = updated.display_name
principal.profile_completed_at = updated.profile_completed_at
if (principal.role == Role.LEARNER or principal.super_admin) and principal.consent_at is None:
principal.consent_at = await record_user_consent(principal.user_id)
return await _profile_for(principal)
@router.post(
"/support-tickets",
response_model=UserSupportTicketResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_support_ticket(
body: UserSupportTicketRequest,
principal: CurrentPrincipal,
) -> UserSupportTicketResponse:
profile = await _profile_for(principal)
try:
async with acquire(role=principal.role.value, user_id=principal.user_id) as conn:
row = await conn.fetchrow(
"""
INSERT INTO app.support_ticket (
reporter_id,
reporter_email,
reporter_name,
reporter_role,
category,
priority,
subject,
body,
source_path
)
VALUES (
$1::uuid,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
$9
)
RETURNING id, category, priority, subject, EXTRACT(EPOCH FROM created_at) AS created_at
""",
principal.user_id,
principal.email,
profile.display_name,
principal.role.value,
body.category,
body.priority,
body.subject.strip(),
body.body.strip(),
body.source_path.strip(),
)
await conn.execute(
"""
INSERT INTO audit.audit_log (
actor_uid, action, target_kind, target_id, detail
)
VALUES ($1::uuid, $2, $3, $4, $5::jsonb)
""",
principal.user_id,
"support_ticket_create",
"support_ticket",
str(row["id"]),
{
"category": row["category"],
"priority": row["priority"],
"source_path": body.source_path.strip(),
"subject_present": bool(body.subject.strip()),
"body_present": bool(body.body.strip()),
},
)
except Exception as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="support ticket persistence unavailable",
) from exc
return UserSupportTicketResponse(
ticket_id=str(row["id"]),
status="open",
category=row["category"],
priority=row["priority"],
subject=row["subject"],
created_at=float(row["created_at"] or 0.0),
)
@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()