"""Current-user profile, preference, and voice-preset routes.""" from __future__ import annotations import secrets import time from pathlib import Path from typing import Literal from fastapi import APIRouter, File, HTTPException, Query, UploadFile, status from pydantic import BaseModel, Field, model_validator 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.support_tickets import support_ticket_fingerprint 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"), } DEFAULT_PREPOST_PILOT_ID = "phase3-pilot-draft" DEFAULT_PREPOST_INSTRUMENT_VERSION = "pilot-prepost-scaffold-2026-06-28" PREPOST_MEASURE_NAMES = ( "self_efficacy", "skill_proficiency", "training_satisfaction", ) PREPOST_TIMEPOINTS = ("pre", "post") 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 account_approval: bool = True 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"] TicketStatus = Literal["open", "triaged", "in_progress", "resolved", "closed"] PrepostMeasureName = Literal["self_efficacy", "skill_proficiency", "training_satisfaction"] PrepostTimepoint = Literal["pre", "post"] 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 class UserSupportTicketListItem(BaseModel): ticket_id: str status: TicketStatus category: TicketCategory priority: TicketPriority subject: str source_path: str assigned_group: str resolution_note: str created_at: float updated_at: float resolved_at: float | None = None class UserSupportTicketsResponse(BaseModel): source: Literal["database"] durable: bool generated_at: float tickets: list[UserSupportTicketListItem] class UserPrepostMeasureRequest(BaseModel): pilot_id: str = Field(default=DEFAULT_PREPOST_PILOT_ID, min_length=1, max_length=80) measure_name: PrepostMeasureName timepoint: PrepostTimepoint raw_score: float min_score: float = 1.0 max_score: float = 5.0 instrument_version: str = Field( default=DEFAULT_PREPOST_INSTRUMENT_VERSION, min_length=1, max_length=120, ) item_count: int = Field(default=1, ge=1, le=80) @model_validator(mode="after") def validate_score_range(self) -> "UserPrepostMeasureRequest": if self.max_score <= self.min_score: raise ValueError("max_score must be greater than min_score") if self.raw_score < self.min_score or self.raw_score > self.max_score: raise ValueError("raw_score must be within min_score and max_score") return self class UserPrepostMeasureItem(BaseModel): measure_id: str pilot_id: str measure_name: PrepostMeasureName timepoint: PrepostTimepoint raw_score: float min_score: float max_score: float normalized_score: float instrument_version: str item_count: int collected_at: float updated_at: float class UserPrepostMeasuresResponse(BaseModel): source: Literal["database"] durable: bool generated_at: float pilot_id: str required_measure_names: list[PrepostMeasureName] required_timepoints: list[PrepostTimepoint] complete_measure_pairs: int measures: list[UserPrepostMeasureItem] _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.can_access_role(Role.LEARNER) 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: subject = body.subject.strip() ticket_body = body.body.strip() source_path = body.source_path.strip() fingerprint = support_ticket_fingerprint( category=body.category, subject=subject, body=ticket_body, source_path=source_path, ) row = await conn.fetchrow( """ INSERT INTO app.support_ticket ( reporter_id, reporter_email, reporter_name, reporter_role, category, priority, subject, body, source_path, fingerprint ) VALUES ( $1::uuid, $2, $3, $4, $5, $6, $7, $8, $9, $10 ) 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, subject, ticket_body, source_path, fingerprint, ) 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": source_path, "fingerprint": fingerprint, "subject_present": bool(subject), "body_present": bool(ticket_body), }, ) 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), ) async def _support_tickets_for_user( principal: CurrentPrincipal, *, limit: int = 20, ) -> UserSupportTicketsResponse: async with acquire(role=principal.role.value, user_id=principal.user_id) as conn: rows = await conn.fetch( """ SELECT id, category, priority, status, subject, source_path, assigned_group, resolution_note, EXTRACT(EPOCH FROM created_at) AS created_at, EXTRACT(EPOCH FROM updated_at) AS updated_at, EXTRACT(EPOCH FROM resolved_at) AS resolved_at FROM app.support_ticket WHERE reporter_id = $1::uuid ORDER BY created_at DESC LIMIT $2 """, principal.user_id, limit, ) return UserSupportTicketsResponse( source="database", durable=True, generated_at=time.time(), tickets=[ UserSupportTicketListItem( ticket_id=str(row["id"]), status=row["status"], category=row["category"], priority=row["priority"], subject=row["subject"], source_path=row["source_path"] or "", assigned_group=row["assigned_group"] or "", resolution_note=row["resolution_note"] or "", created_at=float(row["created_at"] or 0.0), updated_at=float(row["updated_at"] or 0.0), resolved_at=float(row["resolved_at"]) if row["resolved_at"] is not None else None, ) for row in rows ], ) @router.get("/support-tickets", response_model=UserSupportTicketsResponse) async def list_my_support_tickets( principal: CurrentPrincipal, ) -> UserSupportTicketsResponse: try: return await _support_tickets_for_user(principal) except Exception as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, detail="support ticket persistence unavailable", ) from exc def _normalized_prepost_score(raw_score: float, min_score: float, max_score: float) -> float: if max_score <= min_score: return 0.0 return round(((raw_score - min_score) / (max_score - min_score)) * 100.0, 3) def _prepost_measure_from_row(row) -> UserPrepostMeasureItem: raw_score = float(row["raw_score"]) min_score = float(row["min_score"]) max_score = float(row["max_score"]) return UserPrepostMeasureItem( measure_id=str(row["id"]), pilot_id=row["pilot_id"], measure_name=row["measure_name"], timepoint=row["timepoint"], raw_score=raw_score, min_score=min_score, max_score=max_score, normalized_score=_normalized_prepost_score(raw_score, min_score, max_score), instrument_version=row["instrument_version"], item_count=int(row["item_count"]), collected_at=float(row["collected_at"] or 0.0), updated_at=float(row["updated_at"] or 0.0), ) async def _prepost_measures_for_user( principal: CurrentPrincipal, *, pilot_id: str = DEFAULT_PREPOST_PILOT_ID, ) -> UserPrepostMeasuresResponse: async with acquire(role=principal.role.value, user_id=principal.user_id) as conn: rows = await conn.fetch( """ SELECT id, pilot_id, measure_name, timepoint, raw_score, min_score, max_score, instrument_version, item_count, EXTRACT(EPOCH FROM collected_at) AS collected_at, EXTRACT(EPOCH FROM updated_at) AS updated_at FROM app.learner_prepost_measure WHERE learner_id = $1::uuid AND pilot_id = $2 ORDER BY measure_name, timepoint, instrument_version """, principal.user_id, pilot_id.strip() or DEFAULT_PREPOST_PILOT_ID, ) measures = [_prepost_measure_from_row(row) for row in rows] pairs = { item.measure_name for item in measures if {m.timepoint for m in measures if m.measure_name == item.measure_name} == {"pre", "post"} } return UserPrepostMeasuresResponse( source="database", durable=True, generated_at=time.time(), pilot_id=pilot_id.strip() or DEFAULT_PREPOST_PILOT_ID, required_measure_names=list(PREPOST_MEASURE_NAMES), required_timepoints=list(PREPOST_TIMEPOINTS), complete_measure_pairs=len(pairs), measures=measures, ) @router.get("/me/prepost-measures", response_model=UserPrepostMeasuresResponse) async def list_my_prepost_measures( principal: CurrentPrincipal, pilot_id: str = Query(default=DEFAULT_PREPOST_PILOT_ID, min_length=1, max_length=80), ) -> UserPrepostMeasuresResponse: try: return await _prepost_measures_for_user(principal, pilot_id=pilot_id) except Exception as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, detail="prepost measure persistence unavailable", ) from exc @router.put("/me/prepost-measures", response_model=UserPrepostMeasureItem) async def upsert_my_prepost_measure( body: UserPrepostMeasureRequest, principal: CurrentPrincipal, ) -> UserPrepostMeasureItem: pilot_id = body.pilot_id.strip() or DEFAULT_PREPOST_PILOT_ID instrument_version = body.instrument_version.strip() or DEFAULT_PREPOST_INSTRUMENT_VERSION try: async with acquire(role=principal.role.value, user_id=principal.user_id) as conn: row = await conn.fetchrow( """ INSERT INTO app.learner_prepost_measure ( learner_id, pilot_id, measure_name, timepoint, raw_score, min_score, max_score, instrument_version, item_count ) VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT ( learner_id, pilot_id, measure_name, timepoint, instrument_version ) DO UPDATE SET raw_score = EXCLUDED.raw_score, min_score = EXCLUDED.min_score, max_score = EXCLUDED.max_score, item_count = EXCLUDED.item_count, collected_at = now(), updated_at = now() RETURNING id, pilot_id, measure_name, timepoint, raw_score, min_score, max_score, instrument_version, item_count, EXTRACT(EPOCH FROM collected_at) AS collected_at, EXTRACT(EPOCH FROM updated_at) AS updated_at """, principal.user_id, pilot_id, body.measure_name, body.timepoint, body.raw_score, body.min_score, body.max_score, instrument_version, body.item_count, ) 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, "prepost_measure_upsert", "learner_prepost_measure", str(row["id"]), { "pilot_id": pilot_id, "measure_name": body.measure_name, "timepoint": body.timepoint, "instrument_version": instrument_version, "item_count": body.item_count, "score_recorded": True, }, ) except Exception as exc: raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, detail="prepost measure persistence unavailable", ) from exc return _prepost_measure_from_row(row) @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()