{"openapi":"3.2.0","info":{"title":"LexFlexer API","description":"Vocabulary learning platform API with spaced repetition and dual authentication support.","version":"1.0.0","contact":{"name":"LexFlexer","email":"support@lexflexer.com","url":"https://lexflexer.com"},"license":{"name":"Proprietary","url":"https://lexflexer.com/api-license"}},"jsonSchemaDialect":"https://spec.openapis.org/oas/3.2/dialect/2025-09-17","security":[{"bearerAuth":[]}],"components":{"securitySchemes":{"cookieAuth":{"type":"apiKey","in":"cookie","name":"connect.sid"},"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT"}},"parameters":{"id":{"name":"id","in":"path","required":true,"description":"Identifies  the vocabulary word to retrieve or modify. Accepts a valid UUID v4 string (e.g., '550e8400-e29b-41d4-a716-446655440000').","schema":{"type":"string","format":"uuid"}},"list_id":{"name":"list_id","in":"path","required":true,"description":"Identifies the user's custom word list to operate on. Accepts a valid UUID v4 string belonging to the authenticated user.","schema":{"type":"string","format":"uuid"}},"wordIdPath":{"name":"word_id","in":"path","required":true,"description":"Identifies which vocabulary word the action applies to. Accepts a valid UUID v4 string.","schema":{"type":"string","format":"uuid"}},"session_id":{"name":"session_id","in":"path","required":true,"description":"Identifies the authentication session for event logging or termination. Accepts a valid UUID v4 string from a previous login response.","schema":{"type":"string","format":"uuid"}},"page":{"name":"page","in":"query","required":true,"description":"Controls which page of results to return for paginated endpoints. Accepts a positive integer starting from 1.","schema":{"type":"integer","minimum":1,"default":1}},"limit":{"name":"limit","in":"query","required":true,"description":"Controls how many results to return per page. Accepts a positive integer, typically 1-100 depending on endpoint.","schema":{"type":"integer","minimum":1,"maximum":100}},"min_rank":{"name":"min_rank","in":"query","required":false,"description":"Sets the lower bound for difficulty filtering to exclude easier words. Accepts an integer from 1 (easiest) to 100.","schema":{"type":"integer","minimum":1,"maximum":100}},"max_rank":{"name":"max_rank","in":"query","required":false,"description":"Sets the upper bound for difficulty filtering to exclude harder words. Accepts an integer from 1 to 100 (hardest).","schema":{"type":"integer","minimum":1,"maximum":100}},"testIdQuery":{"name":"test_id","in":"query","required":true,"description":"Links the action to a specific test session for tracking progress and statistics. Accepts a valid UUID v4 string representing an active test session.","schema":{"type":"string","format":"uuid"}},"testTypeQuery":{"name":"testType","in":"query","required":false,"description":"Specifies which learning mode the test uses. Accepts integer 1 for spelling tests (audio to text) or 2 for definition tests (definition to word).","schema":{"type":"integer","enum":[1,2]}}},"schemas":{"AiBatchBody":{"type":"object","description":"Shared request body for every /api/admin/ai-helpers/*-random endpoint. All fields are optional. Send an empty body `{}` to pick a random word, `{ word_id }` to target one specific word, `{ word_ids }` to target a batch, or `{ count: true }` to skip processing and return only the remaining count for this pipeline.","required":[],"properties":{"word_id":{"type":"string","format":"uuid","default":"","description":"Optional. Process a specific word instead of picking a random one. An empty string or omitted field triggers random selection."},"word_ids":{"type":"array","nullable":true,"items":{"type":"string","format":"uuid"},"maxItems":100,"description":"Optional. Process up to 100 specific words. Takes precedence over word_id when both are sent."},"count":{"type":"boolean","nullable":true,"description":"Optional. When true, the endpoint skips processing and returns only `{ remaining: integer }` — the count of words still pending for this pipeline. Use to poll pipeline depth without spending AI credits."}}},"MessageResponse":{"type":"object","description":"Simple message response indicating operation result","properties":{"success":{"type":"boolean","example":true},"message":{"type":"string","description":"Human-readable status message"}},"required":["success","message"],"examples":[{"success":true,"message":"Operation completed successfully"}]},"ChapterStats":{"type":"object","description":"Per-chapter progress for the per-book stats endpoint.","required":["chapter_id","chapter_name","sort_order","total_questions","tested_count","mastered_count","in_progress_count","mastery_percent","average_score","last_tested"],"properties":{"chapter_id":{"type":"string","format":"uuid"},"chapter_name":{"type":"string"},"sort_order":{"type":"integer"},"total_questions":{"type":"integer","description":"Questions defined in the chapter (independent of the user)."},"tested_count":{"type":"integer","description":"Questions where this user has a tests row (test_type=4)."},"mastered_count":{"type":"integer","description":"tested_count where answer_score = 1."},"in_progress_count":{"type":"integer","description":"tested_count where 0 < answer_score < 1."},"mastery_percent":{"type":"number","format":"float","description":"mastered_count / total_questions * 100, or 0 if total_questions = 0."},"average_score":{"type":"number","format":"float","description":"Mean answer_score across tested questions, 0 if none tested."},"last_tested":{"type":"string","format":"date-time","nullable":true,"description":"Most recent attempt timestamp for any question in this chapter."}}},"BookStats":{"type":"object","description":"Book metadata + roll-up + per-chapter stats for the per-book progress UI.","required":["book_id","book_name","book_description","book_image_url","total_chapters","total_questions","mastered_questions","mastery_percent","last_tested","chapters"],"properties":{"book_id":{"type":"string","format":"uuid"},"book_name":{"type":"string"},"book_description":{"type":"string","description":"Book description from lists.description; empty string when unset."},"book_image_url":{"type":"string","description":"Cover image URL from lists.image_url; empty string when unset."},"total_chapters":{"type":"integer"},"total_questions":{"type":"integer","description":"Sum of total_questions across chapters."},"mastered_questions":{"type":"integer","description":"Sum of mastered_count across chapters."},"mastery_percent":{"type":"number","format":"float","description":"mastered_questions / total_questions * 100, or 0 if total_questions = 0."},"last_tested":{"type":"string","format":"date-time","nullable":true,"description":"Most recent attempt timestamp across the whole book."},"chapters":{"type":"array","items":{"$ref":"#/components/schemas/ChapterStats"},"description":"Ordered by sort_order ASC."}}},"ErrorResponse":{"type":"object","description":"Error response with details","properties":{"success":{"type":"boolean","example":false},"error":{"type":"string","description":"Error message describing what went wrong"}},"required":["success","error"],"examples":[{"success":false,"error":"Invalid request parameters"}]},"EmailInput":{"type":"object","description":"Request containing only an email address","required":["email"],"properties":{"email":{"type":"string","format":"email","description":"User's email address. Accepts a valid email format."}},"examples":[{"email":"user@example.com"}]},"OtpVerifyInput":{"type":"object","description":"OTP verification request","required":["email","code"],"properties":{"email":{"type":"string","format":"email","description":"Email address the OTP was sent to."},"code":{"type":"string","pattern":"^[0-9]{8}$","description":"8-digit one-time password code."}},"examples":[{"email":"user@example.com","code":"12345678"}]},"ResetPasswordInput":{"type":"object","description":"Password reset request with token","required":["reset_token","new_password"],"properties":{"reset_token":{"type":"string","description":"Password reset token from email link (64-character hex string)."},"new_password":{"type":"string","minLength":12,"description":"New password. Accepts a string with minimum 12 characters."}},"examples":[{"reset_token":"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2","new_password":"newSecurePassword456!"}]},"AuthTokenResponse":{"type":"object","description":"JWT authentication tokens response","properties":{"access_token":{"type":"string","description":"JWT access token for API authorization"},"refresh_token":{"type":"string","description":"Refresh token for obtaining new access tokens"}},"required":["access_token","refresh_token"],"examples":[{"access_token":"eyJhbGciOiJIUzI1NiIs...","refresh_token":"eyJhbGciOiJIUzI1NiIs..."}]},"User":{"type":"object","description":"User profile information","properties":{"id":{"type":"string","format":"uuid","description":"Unique user identifier"},"email":{"type":"string","format":"email","description":"User's email address"},"min_rank":{"type":"integer","minimum":1,"maximum":100,"description":"Minimum difficulty preference"},"max_rank":{"type":"integer","minimum":1,"maximum":100,"description":"Maximum difficulty preference"},"history_length":{"type":"integer","description":"Number of recent words to track"},"list_id":{"type":["string","null"],"format":"uuid","description":"Active list ID"},"role":{"type":"string","enum":["user","admin"],"description":"User role"},"word_category":{"type":"string","enum":["","verb","noun","adjective","adverb"],"description":"Filter vocabulary by word category. Empty string means all categories."},"untested_threshold":{"type":"integer","minimum":10,"maximum":30,"default":20,"description":"Minimum number of untested words in pool before new words are added"},"username":{"type":["string","null"],"description":"Display name, auto-generated on signup (e.g. FrostWolfEmber). User can change via PATCH. 3-30 chars, alphanumeric + underscores, must be unique."},"interest_sort":{"type":"string","enum":["overall","sound","depth","etymology","timelessness","imagery","uniqueness","story","emotion","versatility","precision"],"default":"overall","description":"Which interest dimension to sort the test word pool by. Controls which words surface first."},"plan":{"type":"string","enum":["free","pro"],"description":"User's subscription plan. Only returned by /api/profile/me, not /api/auth/jwt/me."},"limits":{"type":"object","additionalProperties":{"type":"integer"},"description":"Plan limits as key-value pairs (e.g. max_lists, max_tests_per_day). Only returned by /api/profile/me."},"book_id":{"type":"string","nullable":true,"description":"Active book ID for book-mode question testing. Mutually exclusive with list_id."}},"required":["id","email"],"examples":[{"id":"550e8400-e29b-41d4-a716-446655440000","email":"user@example.com","username":"FrostWolfEmber","min_rank":1,"max_rank":50,"history_length":5,"untested_threshold":20,"interest_sort":"overall","list_id":null,"role":"user","word_category":""}]},"UserResponse":{"type":"object","description":"Response containing user profile","properties":{"user":{"$ref":"#/components/schemas/User"}},"required":["user"],"examples":[{"user":{"id":"550e8400-e29b-41d4-a716-446655440000","email":"user@example.com","username":"FrostWolfEmber","min_rank":1,"max_rank":50,"history_length":5,"untested_threshold":20,"interest_sort":"overall","list_id":null,"role":"user","word_category":""}}]},"UserlistInput":{"type":"object","description":"Userlist creation/update input","required":["name"],"properties":{"name":{"type":"string","minLength":3,"maxLength":20,"description":"Name of the word list. Accepts 3-20 characters."},"description":{"type":"string","maxLength":50,"description":"Optional description of the list's purpose. Maximum 50 characters."}},"examples":[{"name":"GRE Vocab","description":"Advanced vocabulary for GRE prep"}]},"List":{"type":"object","description":"Unified list shape — used for user-owned lists, marketplace templates, and admin views. All list GET endpoints return this same structure.","properties":{"id":{"type":"string","format":"uuid","description":"Unique list identifier"},"user_id":{"type":"string","format":"uuid","description":"Owner's user ID"},"name":{"type":"string","description":"Name of the list"},"description":{"type":"string","nullable":true,"description":"Description of the list"},"is_marketplace":{"type":"boolean","description":"Whether this is a marketplace list"},"published":{"type":"boolean","description":"Whether the list is publicly visible"},"image_url":{"type":"string","nullable":true,"description":"Cover image URL"},"difficulty_level":{"type":"string","nullable":true,"enum":["Rookie","Explorer","Challenger","Mastermind","Genius","Unranked"],"description":"Difficulty level"},"author":{"type":"string","nullable":true,"description":"Author name"},"tags":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Tags for categorisation"},"type":{"type":"string","enum":["vocab","book","chapter"],"description":"List content type"},"parent_id":{"type":"string","format":"uuid","nullable":true,"description":"Parent list ID (for chapter lists). Book lists cannot have a parent."},"is_readable":{"type":"boolean","description":"Whether the chapter's content is readable by users. Only meaningful for type=chapter."},"has_audio":{"type":"boolean","description":"User-controlled publish flag indicating this chapter's audio is ready for listeners. Only meaningful for type=chapter."},"show_copyright":{"type":"boolean","description":"Whether to display copyright/licensing information for this list's content."},"skip_mastery":{"type":"boolean","description":"Whether to skip mastery tracking for items in this list."},"is_wiki":{"type":"boolean","description":"Whether this list was imported from Wikipedia."},"wiki_source_url":{"type":"string","description":"Source URL of the Wikipedia article this list was imported from."},"copyright_information":{"type":"string","description":"Copyright and licensing text (e.g. 'Content from Wikipedia, licensed under CC BY-SA 4.0')."},"r2_audio":{"type":"boolean","nullable":true,"description":"Whether chapter-level audio exists on R2. null = never generated, true = audio at audio.lexflexer.com/audio/{id}.wav, false = generation failed."},"enriched":{"type":"boolean","description":"Whether this chapter has been AI-enriched (glossary generated)."},"sort_order":{"type":"integer","description":"Position for drag-drop reordering. Gap-based (1000 spacing). Lower values sort first."},"date_created":{"type":"string","format":"date-time","description":"Creation timestamp"},"item_count":{"type":"integer","description":"Number of words (list_items rows) in the list. Always 0 for type=book (books hold chapters, not words)."},"child_count":{"type":"integer","description":"Number of child lists with parent_id pointing at this list. Non-zero only for type=book (chapter count); 0 for vocab and chapter lists."},"selected":{"type":"boolean","description":"On /api/lists/marketplace: true if the user has linked this list to their collection (list_access row exists). On /api/lists/user: true if this is the user's active test pool (users.list_id or users.book_id). Only present on user/marketplace/single endpoints."},"subscribers":{"type":"integer","description":"Number of users who have linked this list to their collection. Only present on marketplace and admin browse endpoints."}},"required":["id","user_id","name","date_created","item_count","child_count"]},"Userlist":{"description":"Alias for List (backward compatibility)","allOf":[{"$ref":"#/components/schemas/List"}]},"VocabularyWord":{"type":"object","description":"Vocabulary word entry","properties":{"id":{"type":"string","format":"uuid","description":"Unique word identifier"},"word":{"type":"string","description":"The vocabulary word"},"definition":{"type":"string","description":"Word definition"},"definition_count":{"type":"integer","description":"Number of definitions for this word"},"difficulty":{"type":"integer","minimum":1,"maximum":100,"description":"Difficulty rank"},"audio_exists":{"type":"boolean","description":"Whether audio pronunciation is available"},"audio_confirmed":{"type":"boolean","description":"Whether audio has been verified"},"audio_altered":{"type":"boolean","description":"Whether audio has been re-saved/modified after initial generation"},"audio_duration":{"type":"integer","nullable":true,"description":"Audio clip duration in milliseconds. Null if not yet calculated. Target range: 1000-1500ms."},"deprecated":{"type":"boolean","description":"Whether word is hidden from tests"},"frequency":{"type":"number","description":"Word frequency score"},"r2_audio":{"type":"boolean","nullable":true,"description":"R2 audio status: true = on R2 and verified, false = upload failed, null = not uploaded (local/DB)"}},"required":["id","word"],"examples":[{"id":"550e8400-e29b-41d4-a716-446655440000","word":"ephemeral","definition":"Lasting for a very short time","difficulty":75,"audio_exists":true,"audio_confirmed":true,"audio_altered":false,"audio_duration":1230,"deprecated":false,"frequency":5.2,"r2_audio":true}]},"WordWithProgress":{"type":"object","description":"Vocabulary word with test progress fields. Built by buildWordWithProgress() in the test service.","required":["id","word","definition","difficulty","answer_score","times_tested","win_streak","lose_streak","answer_history"],"properties":{"id":{"type":"string","format":"uuid","description":"Word ID"},"word":{"type":"string","description":"The vocabulary word"},"definition":{"type":"string","description":"Word definition"},"difficulty":{"type":"integer","description":"Difficulty rank (1-100)"},"date":{"type":"string","format":"date-time","description":"Date field from vocabulary"},"answer_score":{"type":"number","description":"Current answer score (0-1)"},"times_tested":{"type":"integer","description":"Total times tested"},"win_streak":{"type":"integer","description":"Consecutive correct answers"},"lose_streak":{"type":"integer","description":"Consecutive incorrect answers"},"answer_history":{"type":"array","items":{"type":"number"},"description":"Rolling answer history"}}},"TestWordResponse":{"type":"object","description":"Response for getword endpoints — word with progress data and optional multiple choice options","required":["word_id","word","test_id","mode"],"properties":{"word_id":{"type":"string","format":"uuid","description":"Selected word ID"},"test_id":{"type":"string","format":"uuid","description":"Test session ID"},"mode":{"type":"string","enum":["spelling","definitionword","worddefinition"],"description":"Test mode for this word"},"word":{"$ref":"#/components/schemas/WordWithProgress"},"options":{"type":"array","items":{"type":"string"},"description":"Multiple choice options (only present for definition tests). The first element is always the correct answer — the client should shuffle before displaying."}}},"TestAnswerInput":{"type":"object","description":"Test answer submission. data_id is the polymorphic dataid (vocabulary.id for vocab tests, questions.id for chapter tests).","required":["data_id","test_type","test_id","time_spent"],"properties":{"data_id":{"type":"string","format":"uuid","description":"ID of the item being tested (vocab word ID for test_type 1-3, question ID for test_type 4)"},"test_type":{"type":"integer","minimum":1,"maximum":4,"description":"1=spelling (audio-to-text), 2=definitionword (word-to-definition), 3=worddefinition (definition-to-word), 4=chapter question."},"test_id":{"type":"string","format":"uuid","description":"Current test session ID"},"time_spent":{"type":"integer","minimum":0,"description":"Time spent in milliseconds"}},"examples":[{"data_id":"550e8400-e29b-41d4-a716-446655440000","test_type":1,"test_id":"6ba7b810-9dad-11d1-80b4-00c04fd430c8","time_spent":5000}]},"TestData":{"type":"object","description":"Test progress data returned after recording an answer.","required":["answer_score","times_tested","win_streak","lose_streak","answer_history","old_score","new_score"],"properties":{"answer_score":{"type":"number","description":"Current answer score (0-1)"},"times_tested":{"type":"integer","description":"Total times this word has been tested"},"win_streak":{"type":"integer","description":"Current consecutive correct answers"},"lose_streak":{"type":"integer","description":"Current consecutive incorrect answers"},"answer_history":{"type":"array","items":{"type":"number"},"description":"Rolling history of answer scores"},"old_score":{"type":"number","description":"Answer score before this answer was recorded"},"new_score":{"type":"number","description":"Answer score after this answer was recorded"}},"examples":[{"answer_score":0.85,"times_tested":5,"win_streak":3,"lose_streak":0,"answer_history":[1,1,1,0,1],"old_score":0.8,"new_score":0.85}]},"TestAnswerResponse":{"type":"object","description":"Response after submitting a test answer","properties":{"message":{"type":"string","description":"Status message"},"test_data":{"$ref":"#/components/schemas/TestData"}},"required":["message","test_data"],"examples":[{"message":"Answer recorded","test_data":{"dataid":"550e8400-e29b-41d4-a716-446655440000","userid":"6ba7b810-9dad-11d1-80b4-00c04fd430c8","test_type":1,"answer_score":0.85,"times_tested":5,"win_streak":3,"lose_streak":0,"answer_history":[1,1,1,0,1],"old_score":0.8,"new_score":0.85}}]},"Quote":{"type":"object","description":"Quote entry with vocabulary context","properties":{"id":{"type":"string","format":"uuid","description":"Unique quote identifier"},"quote":{"type":"string","description":"The quote text"},"author":{"type":["string","null"],"description":"Quote author. Null for AI-generated example sentences."},"quote_type":{"type":"string","enum":["quote","example"],"description":"Type of entry: 'quote' for real quotes with attribution, 'example' for AI-generated usage sentences"},"dateadded":{"type":"string","format":"date-time","description":"When quote was added"},"quote_parsed":{"type":"boolean","description":"Whether vocabulary words have been extracted"}},"required":["id","quote"],"examples":[{"id":"550e8400-e29b-41d4-a716-446655440000","quote":"The only way to do great work is to love what you do.","author":"Steve Jobs","quote_type":"quote","quote_parsed":true},{"id":"660e8400-e29b-41d4-a716-446655440001","quote":"The professor's oracular pronouncements about the economy left the students debating whether she was genuinely insightful or simply confident enough to sound prophetic.","author":null,"quote_type":"example","quote_parsed":true}]},"QuoteInput":{"type":"object","description":"New quote submission","required":["quote","author"],"properties":{"quote":{"type":"string","minLength":10,"maxLength":2000,"description":"The quote text (10-2000 characters after trimming)"},"author":{"type":"string","minLength":1,"maxLength":200,"description":"Quote author or source (required, max 200 characters after trimming)"},"source":{"type":"string","maxLength":200,"description":"Optional reference source (book title, URL, etc.). Max 200 characters after trimming."},"quote_type":{"type":"string","enum":["quote","example"],"default":"quote","description":"Distinguishes real author-attributed quotes from AI-generated example sentences. Defaults to 'quote'."}},"examples":[{"quote":"The only way to do great work is to love what you do.","author":"Steve Jobs"}]},"BlogPost":{"type":"object","description":"A blog post with SEO metadata","properties":{"id":{"type":"string","format":"uuid","description":"Unique blog post identifier"},"title":{"type":"string","description":"Display title of the blog post"},"slug":{"type":"string","description":"URL-friendly identifier (e.g., 'how-to-improve-vocabulary')"},"summary":{"type":"string","maxLength":200,"description":"Short summary of the blog post"},"image":{"type":"string","nullable":true,"description":"Image URL"},"image_alt":{"type":"string","nullable":true,"description":"Image alt text for accessibility and SEO"},"image_seo":{"type":"string","nullable":true,"description":"Image title/caption for SEO"},"meta_title":{"type":"string","nullable":true,"maxLength":70,"description":"Custom <title> tag content (falls back to title if null)"},"meta_description":{"type":"string","nullable":true,"maxLength":160,"description":"Meta description for search engines (~155 chars recommended)"},"canonical_url":{"type":"string","nullable":true,"description":"Canonical URL to prevent duplicate content penalties"},"og_title":{"type":"string","nullable":true,"description":"Open Graph title for social sharing"},"og_description":{"type":"string","nullable":true,"description":"Open Graph description for social sharing"},"og_image":{"type":"string","nullable":true,"description":"Open Graph image URL (falls back to image if null)"},"keywords":{"type":"string","nullable":true,"description":"Comma-separated keywords for meta tag"},"structured_data":{"type":"string","nullable":true,"description":"JSON-LD structured data for Article schema (rich snippets)"},"html_body":{"type":"string","description":"HTML content body of the blog post"},"author":{"type":"string","description":"Post author name"},"reading_time_minutes":{"type":"integer","nullable":true,"description":"Estimated reading time in minutes"},"published":{"type":"boolean","description":"Whether the post is publicly visible"},"featured":{"type":"boolean","description":"Whether the post is featured on the homepage"},"creation_date":{"type":"string","format":"date-time","description":"Creation timestamp"},"date_updated":{"type":"string","format":"date-time","description":"Last update timestamp"},"date_published":{"type":"string","format":"date-time","nullable":true,"description":"First publication timestamp (null if never published)"}},"required":["id","title","slug","summary","html_body","author","published","featured","creation_date","date_updated"]},"BlogPostInput":{"type":"object","description":"Input for creating or updating a blog post","required":["title","summary","html_body"],"properties":{"title":{"type":"string","maxLength":200,"description":"Display title (required, max 200 characters)"},"slug":{"type":"string","maxLength":200,"description":"URL-friendly slug. Auto-generated from title if omitted. Lowercase letters, numbers, and hyphens only."},"summary":{"type":"string","maxLength":200,"description":"Short summary of the blog post (required, max 200 characters)"},"image":{"type":"string","description":"Image URL"},"image_alt":{"type":"string","description":"Image alt text for accessibility and SEO"},"image_seo":{"type":"string","description":"Image title/caption for SEO"},"meta_title":{"type":"string","maxLength":70,"description":"Custom <title> tag (max 70 chars, Google truncates at ~60)"},"meta_description":{"type":"string","maxLength":160,"description":"Meta description (max 160 chars, Google truncates at ~155)"},"canonical_url":{"type":"string","description":"Canonical URL if cross-posted elsewhere"},"og_title":{"type":"string","description":"Open Graph title for social sharing"},"og_description":{"type":"string","description":"Open Graph description for social sharing"},"og_image":{"type":"string","description":"Open Graph image URL"},"keywords":{"type":"string","maxLength":500,"description":"Comma-separated keywords"},"structured_data":{"type":"string","description":"JSON-LD structured data string for Article schema"},"html_body":{"type":"string","description":"HTML content body of the blog post"},"author":{"type":"string","maxLength":200,"description":"Post author name"},"reading_time_minutes":{"type":"integer","description":"Estimated reading time in minutes"},"published":{"type":"boolean","description":"Set to true to publish, false for draft"},"featured":{"type":"boolean","description":"Set to true to feature on homepage"},"creation_date":{"type":"string","format":"date-time","description":"Override the creation date (date_added). Only accepted on PATCH."}},"examples":[{"title":"10 Tips to Improve Your Vocabulary","description":"Here are ten proven strategies to expand your vocabulary...","slug":"10-tips-to-improve-your-vocabulary","meta_title":"10 Tips to Improve Your Vocabulary | LexFlexer","meta_description":"Discover 10 proven strategies to expand your vocabulary quickly and effectively.","keywords":"vocabulary, learning, tips, english, words","author":"LexFlexer Team","published":true}]},"MarketplaceList":{"description":"Alias for List (backward compatibility — marketplace lists use the same unified shape)","allOf":[{"$ref":"#/components/schemas/List"}]},"MarketplaceListWithWords":{"allOf":[{"$ref":"#/components/schemas/List"},{"type":"object","properties":{"words":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"word":{"type":"string"},"definition":{"type":"string"},"definition_count":{"type":"integer","description":"Number of definitions for this word"},"difficulty":{"type":"integer"}}},"description":"Words in the marketplace list"}}}]},"MarketplaceListCreateInput":{"type":"object","required":["title"],"properties":{"title":{"type":"string","maxLength":200,"description":"List title"},"description":{"type":"string","maxLength":1000,"nullable":true,"description":"List description"},"difficulty_level":{"type":"string","nullable":true,"enum":["Rookie","Explorer","Challenger","Mastermind","Genius","Unranked"],"description":"Difficulty level"},"author":{"type":"string","nullable":true,"description":"Author name (defaults to 'LexFlexer')"},"image_url":{"type":"string","nullable":true,"description":"URL of the list cover image"}}},"MarketplaceListBrowseResponse":{"type":"object","properties":{"lists":{"type":"array","items":{"$ref":"#/components/schemas/MarketplaceList"}},"total":{"type":"integer","description":"Total number of matching lists"},"limit":{"type":"integer"},"offset":{"type":"integer"}},"required":["lists","total","limit","offset"]},"MarketplaceAdminBrowseResponse":{"type":"object","properties":{"lists":{"type":"array","items":{"$ref":"#/components/schemas/List"}},"total":{"type":"integer","description":"Total matching lists (respects current filters)"},"limit":{"type":"integer"},"offset":{"type":"integer"},"published_count":{"type":"integer","description":"Total published lists (unfiltered)"},"unpublished_count":{"type":"integer","description":"Total unpublished/draft lists (unfiltered)"},"difficulty_counts":{"type":"object","description":"Total lists per difficulty level (unfiltered)","properties":{"Rookie":{"type":"integer"},"Explorer":{"type":"integer"},"Challenger":{"type":"integer"},"Mastermind":{"type":"integer"},"Genius":{"type":"integer"},"":{"type":"integer","description":"Lists with no difficulty set"}}}},"required":["lists","total","limit","offset","published_count","unpublished_count","difficulty_counts"]}}},"tags":[{"name":"System","description":"System health and status endpoints"},{"name":"Auth (JWT)","description":"JWT-based authentication endpoints"},{"name":"Auth (Session)","description":"Session-based authentication endpoints"},{"name":"Auth Sessions","description":"Auth session management and events"},{"name":"Profile","description":"User profile management"},{"name":"Vocabulary","description":"Vocabulary word search and retrieval"},{"name":"Quotes","description":"Quote browsing and management"},{"name":"Tests","description":"Vocabulary test endpoints"},{"name":"Userlists","description":"User custom word lists"},{"name":"Speech","description":"Audio pronunciation endpoints"},{"name":"Admin","description":"Admin-only vocabulary and system management"},{"name":"Analytics","description":"Usage analytics and logging"},{"name":"Docs","description":"API documentation endpoints"},{"name":"Blog","description":"Blog post endpoints (public read, admin write)"},{"name":"Marketplace","description":"Marketplace vocabulary list templates (public browse, user add, admin manage)"},{"name":"Admin - Dictionary Enrichment","description":"Dictionary enrichment, word validation, and public readiness management"},{"name":"Public","description":"Public endpoints requiring no authentication"},{"name":"Admin - Stats","description":"Admin dashboard statistics — user activity, signups, API traffic, session metrics"}],"paths":{"/api/vocabulary/search":{"get":{"summary":"Search Vocabulary","description":"Search for words in the vocabulary database with ILIKE pattern matching and rank-based filtering. Results are ranked by relevance: exact match, prefix match, suffix match, word contains, definition contains. Optionally filter by category (verb, noun, adjective, adverb). Only returns words with audio and definitions. Excludes words already in the specified userlist.","tags":["Vocabulary"],"operationId":"searchVocabulary","security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"word_search","in":"query","description":"Search term to match against word names (and derived forms). Results are ranked by relevance: exact > prefix > suffix > contains > derived form.","required":false,"schema":{"type":"string"}},{"name":"definition_search","in":"query","description":"Search term to match against word definitions. Filters to words that have a definition containing this text.","required":false,"schema":{"type":"string"}},{"name":"list_id","in":"query","description":"ID of the userlist to exclude words from. Required — returns 400 if missing.","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/min_rank"},{"$ref":"#/components/parameters/max_rank"},{"name":"category","in":"query","description":"Filter by word category. Pass 'all' or omit to return all categories.","required":false,"schema":{"type":"string","enum":["all","verb","noun","adjective","adverb"],"default":"all"}},{"name":"limit","in":"query","description":"Maximum number of results to return (default 20, max 20)","required":false,"schema":{"type":"integer","minimum":1,"maximum":20,"default":20}}],"responses":{"200":{"description":"Array of matching vocabulary words. Returns empty array if no matches found.","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","required":["id","word","definition","difficulty","category","audio_exists"],"properties":{"id":{"type":"string","description":"Vocabulary word UUID"},"word":{"type":"string","description":"The vocabulary word"},"definition":{"type":"string","description":"Word definition"},"definition_count":{"type":"integer","description":"Number of definitions for this word"},"difficulty":{"type":"integer","description":"Difficulty rank (1-100)"},"category":{"type":"string","description":"Word category"},"audio_exists":{"type":"boolean","description":"Whether audio pronunciation exists"},"search_rank":{"type":"integer","description":"Relevance ranking (1=exact, 2=prefix, 3=word contains, 4=definition contains, 5=default). Only present when searchTerm is provided."}}}},"example":[{"id":"550e8400-e29b-41d4-a716-446655440000","word":"ephemeral","definition":"lasting a short time","difficulty":72,"category":"adjective","audio_exists":true,"search_rank":1}]}}},"400":{"description":"Missing or invalid list_id parameter","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"missing":{"summary":"list_id not provided","value":{"error":"list_id is mandatory"}},"invalid":{"summary":"list_id not found or not owned by user","value":{"error":"list_id is invalid or does not belong to you"}}}}}},"401":{"description":"Unauthorized - Authentication required or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Internal Server Error"}}}}}}},"/api/vocabulary/search/count":{"get":{"summary":"Count Matching Vocabulary","description":"Returns the total number of vocabulary words matching the given filters, without applying any limit. Useful for showing the user how many words are available before fetching a paginated subset.","tags":["Vocabulary"],"operationId":"countVocabularySearch","security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"word_search","in":"query","description":"Search term to match against word names and derived forms.","required":false,"schema":{"type":"string"}},{"name":"definition_search","in":"query","description":"Search term to match against word definitions.","required":false,"schema":{"type":"string"}},{"name":"list_id","in":"query","description":"ID of the userlist to exclude words from. Required.","required":true,"schema":{"type":"string"}},{"$ref":"#/components/parameters/min_rank"},{"$ref":"#/components/parameters/max_rank"},{"name":"category","in":"query","description":"Filter by word category. Pass 'all' or omit to return all categories.","required":false,"schema":{"type":"string","enum":["all","verb","noun","adjective","adverb"],"default":"all"}}],"responses":{"200":{"description":"Total count of matching words.","content":{"application/json":{"schema":{"type":"object","required":["total_words"],"properties":{"total_words":{"type":"integer","description":"Total number of matching vocabulary words"}}},"example":{"total_words":1234}}}},"400":{"description":"Missing or invalid parameters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quotes/{id}":{"delete":{"summary":"Delete Quote","description":"Delete a quote and its associated details (Admin only)","tags":["Quotes"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Quote UUID"}],"responses":{"200":{"description":"Quote and associated details deleted successfully","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}}},"example":{"message":"Quote and associated details deleted successfully"}}}},"400":{"description":"quote_id is required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"quote_id is required"}}}},"401":{"description":"Unauthorized - Authentication required or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Forbidden - Admin access required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Admin access required"}}}},"404":{"description":"Quote not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Quote not found"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to delete quote"}}}}},"operationId":"deleteQuote","x-requires-admin":true}},"/api/quotes/rebuild":{"post":{"summary":"Rebuild Quote Assignments","description":"Clears all example_quote_id and example_quote_last_updated from vocabulary, then re-assigns a random quote to each word that has one in quote_details. Use after bulk adding or deleting quotes.","operationId":"rebuildQuoteAssignments","tags":["Quotes"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"responses":{"200":{"description":"Quote assignments rebuilt","content":{"application/json":{"schema":{"type":"object","properties":{"parsed":{"type":"integer","description":"Number of quotes re-parsed"},"cleared":{"type":"integer","description":"Number of vocabulary records cleared"},"assigned":{"type":"integer","description":"Number of vocabulary records that received a quote"},"pruned":{"type":"integer","description":"Number of excess quote_details pruned"}}},"example":{"parsed":150,"cleared":500,"assigned":320,"pruned":5}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin access required"},"500":{"description":"Failed to rebuild quote assignments"}},"x-requires-admin":true}},"/api/quotes/parse-wikiquote":{"post":{"summary":"Parse Wikiquote Page","description":"Fetches and parses a specific Wikiquote page, extracts quotes, and saves them to the database. Accepts a Wikiquote URL or page title. Pass an optional author to override the page-level author (useful for author pages like 'Hermann Hesse'). Pass an optional source to override section headings. Quotes containing brackets []{}() are rejected. Returns parsed quotes plus saved/skipped/rejected counts.","operationId":"parseWikiquotePage","tags":["Quotes"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url"],"properties":{"url":{"type":"string","description":"Wikiquote page URL (e.g. https://en.wikiquote.org/wiki/Atlas_Shrugged) or page title (e.g. Atlas Shrugged)"},"author":{"type":"string","description":"Optional author override. If provided, all quotes use this author instead of parsing from the page. Useful for author pages (e.g. 'Hermann Hesse')."},"source":{"type":"string","description":"Optional source override. If provided, all quotes use this as their source instead of the section heading (e.g. 'Siddhartha')."}}},"example":{"url":"https://en.wikiquote.org/wiki/Hermann_Hesse","author":"Hermann Hesse","source":"Siddhartha"}}}},"responses":{"200":{"description":"Parsed quotes from the Wikiquote page","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"title":{"type":"string","description":"Page title"},"page_author":{"type":["string","null"],"description":"Author extracted from the page header, if any"},"quotes":{"type":"array","items":{"type":"object","properties":{"quote":{"type":"string","description":"The quote text, stripped of wiki markup"},"author":{"type":"string","description":"Author of the quote"},"source":{"type":"string","description":"Source of the quote — section heading or override value (e.g. book title, chapter)"}}}}}},"example":{"success":true,"title":"Hermann Hesse","page_author":"Hermann Hesse","quotes":[{"quote":"The bird fights its way out of the egg. The egg is the world. Who would be born must first destroy a world.","author":"Hermann Hesse","source":"Siddhartha"}]}}}},"400":{"description":"Missing url parameter"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden — admin access required"},"500":{"description":"Failed to fetch or parse the Wikiquote page"}},"x-requires-admin":true}},"/api/admin/ai-helpers/sync-public-ready":{"post":{"summary":"Sync Public Ready Status (batch)","description":"Processes 5 words per call. Promotes words that meet all quality criteria to public_ready=true, demotes words that no longer qualify. Frontend loops until remaining=0. Criteria: not deprecated, not flagged_invalid, R2 audio confirmed, no report errors, difficulty 1-100, has etymology, category, phonetic pronunciation, interest scored, >= 3 definitions, >= 3 example sentences.","operationId":"syncPublicReady","tags":["Admin - AI Helpers"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"responses":{"200":{"description":"Batch sync results","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"error":{"type":"boolean"},"processed":{"type":"integer","description":"Words processed this batch (promoted + demoted)"},"remaining":{"type":"integer","description":"Words still needing promote or demote. Loop until 0."},"promoted":{"type":"integer","description":"Words promoted to public_ready=true this batch"},"demoted":{"type":"integer","description":"Words demoted to public_ready=false this batch"},"total_ready":{"type":"integer","description":"Total public_ready=true words"},"results":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"word":{"type":"string"},"action":{"type":"string","enum":["promoted","demoted"]}}}}}}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin access required"},"500":{"description":"Failed to sync public_ready"}},"x-requires-admin":true}},"/api/admin/ai-helpers/example-random":{"post":{"summary":"Generate AI Example Sentences","description":"Generates 3 AI example sentences for a vocabulary word using Claude Haiku. Each sentence is 30-60 words demonstrating the word in natural, varied contexts. Sentences are stored as quotes with quote_type='example' and no author. Requires the word to have at least one definition. Follows the standard batch pattern.","operationId":"generateExampleRandom","tags":["Quotes"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AiBatchBody"},"example":{}}}},"responses":{"200":{"description":"Batch result","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"error":{"type":"boolean"},"error_message":{"type":"string"},"error_word":{"type":"string"},"processed":{"type":"integer","description":"Number of words processed"},"remaining":{"type":"integer","description":"Number of words still needing example sentences"},"results":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"word":{"type":"string"},"sentences_added":{"type":"integer"},"invalid":{"type":"boolean"}}}}}},"example":{"success":true,"error":false,"processed":1,"remaining":42,"results":[{"id":"abc-123","word":"oracular","sentences_added":3}]}}}},"400":{"description":"Validation error — invalid request body/params/query"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin access required"},"500":{"description":"Internal server error"}},"x-requires-admin":true}},"/api/health":{"get":{"summary":"Health Check","description":"Returns API health status based on database connectivity and table verification. Checks that all 16 required tables exist. No authentication required.","operationId":"getHealth","tags":["System"],"security":[],"responses":{"200":{"description":"All systems healthy","content":{"application/json":{"schema":{"type":"object","required":["status","database","tables"],"properties":{"status":{"type":"string","enum":["healthy"]},"database":{"type":"string","enum":["connected"]},"tables":{"type":"string","enum":["ok"]}}},"example":{"status":"healthy","database":"connected","tables":"ok"}}}},"503":{"description":"Unhealthy — database unreachable or tables missing","content":{"application/json":{"schema":{"type":"object","required":["status"],"properties":{"status":{"type":"string","enum":["unhealthy"]},"database":{"type":"string","enum":["connected","unreachable"],"description":"Whether the database connection succeeded"},"missing_tables":{"type":"array","items":{"type":"string"},"description":"List of required tables not found in the database"}}},"examples":{"tables_missing":{"summary":"Database connected but tables missing","value":{"status":"unhealthy","database":"connected","missing_tables":["users","vocabulary"]}},"db_unreachable":{"summary":"Database unreachable","value":{"status":"unhealthy","database":"unreachable"}}}}}}}}},"/api/lists/user":{"get":{"summary":"Get user's lists","description":"Returns all lists in the user's collection (owned + linked marketplace). Each item includes a `selected` boolean indicating whether this list is the user's currently-active selection. Defaults to top-level only (parent_id IS NULL).","operationId":"getUserLists","tags":["Userlists"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"parent_id","in":"query","required":false,"schema":{"type":"string"},"description":"Filter by parent list. Pass a UUID to get children of that list. Omit to get top-level lists only — the default."},{"name":"search","in":"query","required":false,"schema":{"type":"string","maxLength":100},"description":"Case-insensitive substring search across `description` and any tag."},{"name":"difficulty","in":"query","required":false,"schema":{"type":"string","enum":["Rookie","Explorer","Challenger","Mastermind","Genius","Unranked"]},"description":"Exact-match filter on `difficulty_level`. Case-insensitive — values are normalised to title case."},{"name":"type","in":"query","required":false,"schema":{"type":"string","enum":["vocab","book","chapter"]},"description":"Exact-match filter on list type."}],"responses":{"200":{"description":"Array of the user's lists","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/List"}},"example":[{"id":"550e8400-e29b-41d4-a716-446655440000","user_id":"660e8400-e29b-41d4-a716-446655440001","name":"GRE Vocab","description":"Advanced vocabulary for GRE prep","is_marketplace":false,"published":false,"image_url":"","difficulty_level":"Mastermind","author":"","tags":["test-prep","academic"],"type":"vocab","parent_id":null,"date_created":"2024-01-15T10:30:00Z","item_count":50,"child_count":0,"selected":true},{"id":"550e8400-e29b-41d4-a716-446655440099","user_id":"660e8400-e29b-41d4-a716-446655440001","name":"SAT Vocab","description":"","is_marketplace":false,"published":false,"image_url":"","difficulty_level":"Challenger","author":"","tags":null,"type":"vocab","parent_id":null,"date_created":"2024-01-10T08:00:00Z","item_count":12,"child_count":0,"selected":false}]}}},"400":{"description":"Invalid query parameter (parent_id not a UUID, invalid difficulty/type, search too long)"},"401":{"description":"Unauthorized"},"429":{"description":"Rate limit exceeded"},"500":{"description":"Internal Server Error"}}}},"/api/lists/marketplace":{"get":{"summary":"Browse marketplace lists","description":"Returns published marketplace lists. Includes `selected` (true when the list matches the user's active `list_id` or `book_id`). Defaults to top-level only (parent_id IS NULL).","operationId":"getMarketplaceLists","tags":["Userlists"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"parent_id","in":"query","required":false,"schema":{"type":"string"},"description":"Filter by parent list. Pass a UUID to get children of that list. Omit to get top-level lists only — the default."},{"name":"search","in":"query","required":false,"schema":{"type":"string","maxLength":100},"description":"Case-insensitive substring search across `description` and any tag."},{"name":"difficulty","in":"query","required":false,"schema":{"type":"string","enum":["Rookie","Explorer","Challenger","Mastermind","Genius","Unranked"]},"description":"Exact-match filter on `difficulty_level`. Case-insensitive — values are normalised to title case."},{"name":"type","in":"query","required":false,"schema":{"type":"string","enum":["vocab","book","chapter"]},"description":"Exact-match filter on list type."}],"responses":{"200":{"description":"Array of marketplace lists","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/List"}}}}},"400":{"description":"Invalid query parameter"},"401":{"description":"Unauthorized"},"429":{"description":"Rate limit exceeded"},"500":{"description":"Internal Server Error"}}}},"/api/lists":{"post":{"summary":"Create list","description":"Creates a new list for the authenticated user. Supports both regular user lists and marketplace lists (set `is_marketplace: true`). All lists are created through this single endpoint.","operationId":"createList","tags":["Userlists"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name"],"properties":{"name":{"type":"string","description":"Name of the list","maxLength":200},"description":{"type":"string","nullable":true,"description":"Description of the list","maxLength":1000},"difficulty_level":{"type":"string","nullable":true,"enum":["Rookie","Explorer","Challenger","Mastermind","Genius","Unranked"],"description":"Difficulty level"},"author":{"type":"string","nullable":true,"description":"Author name"},"image_url":{"type":"string","nullable":true,"description":"Cover image URL"}}},"example":{"name":"GRE Vocabulary","description":"Advanced vocabulary for GRE prep","difficulty_level":"Challenger","author":"LexFlexer"}}}},"responses":{"201":{"description":"List created successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":"string","nullable":true},"is_marketplace":{"type":"boolean"},"published":{"type":"boolean"},"image_url":{"type":"string"},"difficulty_level":{"type":"string"},"author":{"type":"string"},"tags":{"type":"array","items":{"type":"string"},"nullable":true},"type":{"type":"string","enum":["vocab","book","chapter"]},"parent_id":{"type":"string","format":"uuid","nullable":true},"is_readable":{"type":"boolean"},"has_audio":{"type":"boolean"},"show_copyright":{"type":"boolean"},"skip_mastery":{"type":"boolean"},"is_wiki":{"type":"boolean"},"wiki_source_url":{"type":"string"},"copyright_information":{"type":"string"},"r2_audio":{"type":"boolean","nullable":true},"enriched":{"type":"boolean"},"sort_order":{"type":"integer"},"date_created":{"type":"string","format":"date-time"},"item_count":{"type":"integer"},"child_count":{"type":"integer"}}}}}},"400":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"name is required"}}}},"401":{"description":"Not authenticated"},"404":{"description":"Resource not found"},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/lists/{list_id}":{"get":{"summary":"Get list by ID","description":"Returns a single list by ID with a normalized response shape. Works for both user-owned lists and marketplace templates — the `is_marketplace` field indicates which.","operationId":"getListById","tags":["Userlists"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"$ref":"#/components/parameters/list_id"}],"responses":{"200":{"description":"List details (unified shape for both owned and marketplace lists)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/List"}}}},"400":{"description":"Invalid or missing userlistId","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Invalid userlist ID format. Must be a UUID."}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"404":{"description":"Userlist not found or user does not own it","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Userlist not found"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Internal Server Error"}}}}}},"patch":{"summary":"Update list metadata","description":"Updates metadata for any list the user owns. Supports name, description, category, difficulty_level, author, and image_url.","operationId":"updateList","tags":["Userlists"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"$ref":"#/components/parameters/list_id"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","maxLength":200},"description":{"type":"string","maxLength":1000},"difficulty_level":{"type":"string","enum":["Rookie","Explorer","Challenger","Mastermind","Genius","Unranked"]},"author":{"type":"string"},"image_url":{"type":"string","nullable":true},"tags":{"type":"array","items":{"type":"string","minLength":1,"maxLength":50},"maxItems":20,"description":"Tags for categorization"},"is_readable":{"type":"boolean","description":"Whether the chapter's content is readable by users"},"has_audio":{"type":"boolean","description":"User-controlled publish flag indicating this chapter's audio is ready for listeners. Only meaningful for type=chapter."},"show_copyright":{"type":"boolean","description":"Whether to display copyright/licensing information"},"copyright_information":{"type":"string","description":"Copyright and licensing text (max 2000 chars)"}}},"example":{"name":"Updated List Name","description":"Updated description","tags":["test-prep","academic"]}}}},"responses":{"200":{"description":"Updated list","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":"string","nullable":true},"is_marketplace":{"type":"boolean"},"published":{"type":"boolean"},"image_url":{"type":"string"},"difficulty_level":{"type":"string"},"author":{"type":"string"},"tags":{"type":"array","items":{"type":"string"},"nullable":true},"type":{"type":"string","enum":["vocab","book","chapter"]},"parent_id":{"type":"string","format":"uuid","nullable":true},"is_readable":{"type":"boolean"},"has_audio":{"type":"boolean"},"show_copyright":{"type":"boolean"},"skip_mastery":{"type":"boolean"},"is_wiki":{"type":"boolean"},"wiki_source_url":{"type":"string"},"copyright_information":{"type":"string"},"r2_audio":{"type":"boolean","nullable":true},"enriched":{"type":"boolean"},"sort_order":{"type":"integer"},"date_created":{"type":"string","format":"date-time"},"item_count":{"type":"integer"},"child_count":{"type":"integer"}}}}}},"400":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated"},"404":{"description":"List not found or user does not own it","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"summary":"Delete Userlist","description":"Deletes a userlist. The list must be empty — returns 400 if it still contains items. Also clears the list from any users who had it selected as their active list.","operationId":"deleteUserlistsByuserlistid","tags":["Userlists"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"$ref":"#/components/parameters/list_id"}],"responses":{"200":{"description":"Userlist deleted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageResponse"},"example":{"message":"Userlist deleted successfully"}}}},"400":{"description":"Invalid ID format or list not empty","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Cannot delete userlist because it contains items. Please empty the list first."}}}},"401":{"description":"Access denied — user does not own the list","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Access denied"}}}},"404":{"description":"Userlist not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Userlist not found"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Internal Server Error"}}}}}}},"/api/lists/{list_id}/words":{"get":{"summary":"Get Userlist Words","description":"Returns all words in a list the user owns or has linked via the marketplace.","operationId":"getUserlistWords","tags":["Userlists"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"$ref":"#/components/parameters/list_id"}],"responses":{"200":{"description":"Array of userlist items with vocabulary details","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","required":["id","list_id","word_id","date_added","word","definition","difficulty"],"properties":{"id":{"type":"string","format":"uuid","description":"List item ID"},"list_id":{"type":"string","format":"uuid","description":"Parent list ID"},"word_id":{"type":"string","format":"uuid","description":"Vocabulary word ID"},"date_added":{"type":"string","format":"date-time","description":"When the word was added to the list"},"word":{"type":"string","description":"The vocabulary word"},"definition":{"type":"string","description":"Word definition"},"definition_count":{"type":"integer","description":"Number of definitions for this word"},"difficulty":{"type":"integer","description":"Difficulty rank (1-100)"}}}},"example":[{"id":"770e8400-e29b-41d4-a716-446655440002","list_id":"550e8400-e29b-41d4-a716-446655440000","word_id":"880e8400-e29b-41d4-a716-446655440003","date_added":"2024-01-16T14:00:00Z","word":"ephemeral","definition":"lasting a short time","difficulty":72}]}}},"401":{"description":"Userlist not found or access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Userlist not found or access denied"}}}},"404":{"description":"Userlist not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Userlist not found"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Internal Server Error"}}}}}},"post":{"summary":"Add Word to Userlist","description":"Adds a vocabulary word to a userlist by wordId. Requires user ownership of a non-marketplace list. Prevents duplicates.","operationId":"addUserlistWord","tags":["Userlists"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"$ref":"#/components/parameters/list_id"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["word_id"],"properties":{"word_id":{"type":"string","format":"uuid","description":"ID of the vocabulary word to add"}}},"example":{"word_id":"880e8400-e29b-41d4-a716-446655440003"}}}},"responses":{"201":{"description":"Word added to userlist","content":{"application/json":{"schema":{"type":"object","required":["success","id","list_id","word_id","date_added"],"properties":{"success":{"type":"boolean","example":true},"id":{"type":"string","format":"uuid","description":"List item ID"},"list_id":{"type":"string","format":"uuid","description":"Parent list ID"},"word_id":{"type":"string","format":"uuid","description":"Vocabulary word ID"},"date_added":{"type":"string","format":"date-time","description":"When the word was added"}}},"example":{"success":true,"id":"770e8400-e29b-41d4-a716-446655440002","list_id":"550e8400-e29b-41d4-a716-446655440000","word_id":"880e8400-e29b-41d4-a716-446655440003","date_added":"2024-01-16T14:00:00Z"}}}},"400":{"description":"Missing wordId","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"word_id is required"}}}},"401":{"description":"Userlist not found or access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Userlist not found or access denied"}}}},"404":{"description":"Userlist not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Userlist not found"}}}},"409":{"description":"Word already in list","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Word already exists in this list"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Internal Server Error"}}}}}},"delete":{"summary":"Clear Userlist Words","description":"Deletes all words from a userlist. Requires user ownership of a non-marketplace list.","operationId":"clearUserlistWords","tags":["Userlists"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"$ref":"#/components/parameters/list_id"}],"responses":{"200":{"description":"All items cleared","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageResponse"},"example":{"message":"Userlist items cleared successfully"}}}},"401":{"description":"Unauthorized - Authentication required or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"User does not own the userlist","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Access denied"}}}},"404":{"description":"Userlist not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Userlist not found"}}}},"500":{"description":"Failed to clear userlist","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to clear userlist"}}}}}}},"/api/lists/{list_id}/parse-text":{"post":{"summary":"Parse Text and Add Words to Userlist","description":"Extracts words from a text string, creates any that don't exist in vocabulary, and adds all extracted words to the userlist. Words must be 3-20 alphabetical characters and non-profane. Newly created words have default difficulty 101 and are unenriched — they will be processed by the batch enrichment pipeline.","operationId":"parseTextToUserlist","tags":["Userlists"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"list_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The userlist to add words to (must be owned by the authenticated user)"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["text"],"properties":{"text":{"type":"string","description":"Raw text to parse for words (max 50,000 characters)","example":"the man with the hat lives here happily"}}}}}},"responses":{"200":{"description":"Words parsed and added to list","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"words_found":{"type":"integer","description":"Total words extracted from text"},"words_created":{"type":"integer","description":"New vocabulary entries created"},"words_added_to_list":{"type":"integer","description":"Words added to the userlist (excludes duplicates already in list)"}}}}}},"400":{"description":"Invalid input (text missing, empty, or too long)"},"401":{"description":"Unauthorized — authentication required"},"404":{"description":"List not found or not owned by user"},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal server error"}}}},"/api/lists/{list_id}/words/{word_id}":{"delete":{"summary":"Remove Word from Userlist","description":"Removes a single word from a userlist by wordId. Requires user ownership of a non-marketplace list.","operationId":"removeUserlistWord","tags":["Userlists"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"$ref":"#/components/parameters/list_id"},{"$ref":"#/components/parameters/wordIdPath"}],"responses":{"200":{"description":"Word removed from userlist","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageResponse"},"example":{"message":"Item removed from userlist"}}}},"401":{"description":"Userlist not found or access denied","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Userlist not found or access denied"}}}},"404":{"description":"Word not on userlist","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Word not on userlist"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Internal Server Error"}}}}}}},"/api/auth-sessions/events":{"post":{"summary":"Log Auth Session Event","description":"Log an event to an auth session for tracking user activity. Caller must own the session.","operationId":"createAuthSessionsEvents","tags":["Auth Sessions"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["session_id","event_type"],"properties":{"session_id":{"type":"string","format":"uuid","description":"The auth session ID returned on login"},"event_type":{"type":"string","description":"Type of event (e.g., 'test_started', 'word_tested', 'page_view')"},"event_data":{"type":"object","description":"Additional event data (optional, JSON stringified before storage)"}}},"example":{"session_id":"550e8400-e29b-41d4-a716-446655440000","event_type":"page_view","event_data":{"page":"/dashboard","duration":30}}}}},"responses":{"200":{"description":"Event logged successfully","content":{"application/json":{"schema":{"type":"object","required":["success","event_id"],"properties":{"success":{"type":"boolean"},"event_id":{"type":"string","format":"uuid"}}},"example":{"success":true,"event_id":"6ba7b810-9dad-11d1-80b4-00c04fd430c8"}}}},"400":{"description":"Missing required fields","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"session_id and event_type are required"}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Session not found or not owned by caller","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Invalid session"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to log event"}}}}}}},"/api/auth-sessions/{session_id}/events":{"get":{"summary":"Get Auth Session Events","description":"Get all events for an auth session, ordered chronologically. Caller must own the session.","operationId":"getAuthSessionsBysessionidEvents","tags":["Auth Sessions"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"$ref":"#/components/parameters/session_id"}],"responses":{"200":{"description":"List of session events","content":{"application/json":{"schema":{"type":"object","required":["events"],"properties":{"events":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"session_id":{"type":"string","format":"uuid"},"event_type":{"type":"string"},"event_data":{"type":["string","null"],"description":"JSON stringified event data"},"created_at":{"type":"string","format":"date-time"}}}}}},"example":{"events":[{"id":"6ba7b810-9dad-11d1-80b4-00c04fd430c8","session_id":"550e8400-e29b-41d4-a716-446655440000","event_type":"login","event_data":"{\"authMethod\":\"jwt\"}","created_at":"2026-02-11T10:00:00.000Z"}]}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Session not found or not owned by caller","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Invalid session"}}}},"404":{"description":"Auth session not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Auth session not found"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to get events"}}}}}}},"/api/auth-sessions/{session_id}/end":{"post":{"summary":"End Auth Session","description":"End an auth session (marks inactive, sets ended_at, logs logout event). Caller must own the session.","operationId":"createAuthSessionsBysessionidEnd","tags":["Auth Sessions"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"$ref":"#/components/parameters/session_id"}],"responses":{"200":{"description":"Session ended","content":{"application/json":{"schema":{"type":"object","required":["success","message"],"properties":{"success":{"type":"boolean"},"message":{"type":"string"}}},"example":{"success":true,"message":"Session ended"}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Session not found or not owned by caller","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Invalid session"}}}},"404":{"description":"Auth session not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Auth session not found"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to end session"}}}}}}},"/api/analytics/heartbeat":{"post":{"summary":"Analytics Heartbeat","description":"Update session activity timestamp. Session ID is passed via x-session-id header, not request body.","operationId":"createAnalyticsHeartbeat","tags":["Analytics"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"x-session-id","in":"header","required":true,"schema":{"type":"string"},"description":"Analytics session ID returned from login"}],"responses":{"200":{"description":"Heartbeat recorded","content":{"application/json":{"schema":{"type":"object","required":["success"],"properties":{"success":{"type":"boolean"}}},"example":{"success":true}}}},"400":{"description":"Missing session ID header","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Session ID required"}}}},"401":{"description":"Unauthorized - JWT authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"404":{"description":"Resource not found"},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to update session activity"}}}}}}},"/api/auth/session/logout":{"post":{"summary":"Logout (Session)","responses":{"200":{"description":"Logged out successfully (was authenticated)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageResponse"},"example":{"message":"Logged out successfully"}}}},"204":{"description":"No active session found (was not authenticated)"},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to log out"}}}}},"operationId":"createAuthLogout","tags":["Auth (Session)"],"description":"Logout (Session). Destroys the session cookie and ends the authentication session. Optionally ends the analytics session via x-session-id header."}},"/api/auth/test/login":{"post":{"summary":"Test Login (E2E only)","description":"**E2E-only endpoint.** Only mounted when the `E2E_TEST_SECRET` environment variable is set on the server. Returns a fresh JWT pair for any email + the correct shared secret, bypassing the OTP flow entirely. Intended for test harnesses that need a token without the email round-trip. In production this route does not exist — gated at registration time in `src/routes/auth-jwt.ts:243`.","operationId":"e2eTestLogin","tags":["Auth (JWT)"],"security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email","secret"],"properties":{"email":{"type":"string","format":"email"},"secret":{"type":"string","description":"Must match server's E2E_TEST_SECRET env var"}}},"example":{"email":"e2e-user@test.com","secret":"<E2E_TEST_SECRET>"}}}},"responses":{"200":{"description":"Returning user — tokens issued","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"access_token":{"type":"string"},"refresh_token":{"type":"string"},"expires_in":{"type":"integer","description":"Seconds until access_token expires"},"is_new_user":{"type":"boolean","example":false},"user":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"},"username":{"type":"string","nullable":true},"role":{"type":"string","enum":["user","admin"]}}}}}}}},"201":{"description":"New user created — tokens issued"},"400":{"description":"Missing email"},"403":{"description":"Invalid or missing test secret"},"500":{"description":"Server error"}}}},"/api/auth/jwt/logout":{"post":{"summary":"Logout (JWT)","responses":{"200":{"description":"Logged out successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageResponse"},"example":{"message":"Logged out successfully"}}}},"204":{"description":"No active session found"},"400":{"description":"Validation error — invalid request body/params/query"},"401":{"description":"Unauthorized - Authentication required or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Forbidden — insufficient permissions or activity limit reached"},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to log out"}}}}},"security":[{"bearerAuth":[]}],"operationId":"createV2AuthLogout","requestBody":{"description":"Request body for JWT-based logout","required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"refresh_token":{"type":"string","description":"Refresh token to revoke. Accepts the refresh token string to invalidate it and prevent further use."}}},"example":{"refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}}}},"tags":["Auth (JWT)"],"description":"Logout (JWT). Ends the current authentication session."}},"/api/auth/session/request-otp":{"post":{"summary":"Request OTP (Session)","description":"Request a one-time login code for session auth. Works for both new and existing users.","tags":["Auth (Session)"],"security":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmailInput"},"example":{"email":"user@example.com"}}},"required":true},"responses":{"200":{"description":"Anti-enumeration response (always returned regardless of whether email exists)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageResponse"},"example":{"message":"If an account exists with this email, a login code has been sent."}}}},"400":{"description":"Bad Request - Invalid email (Zod validation)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Invalid email address","details":[{"code":"invalid_string","message":"Please enter a valid email address","path":["email"]}]}}}},"429":{"description":"Too Many Requests - Rate limit exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Rate limit exceeded. Try again later."}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to process request"}}}}},"operationId":"createAuthRequestOtp"}},"/api/auth/session/verify-otp":{"post":{"summary":"Verify OTP (Session)","description":"Verify OTP code for session auth. Creates account if user doesn't exist.","tags":["Auth (Session)"],"security":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OtpVerifyInput"},"example":{"email":"user@example.com","code":"12345678"}}},"required":true},"responses":{"200":{"description":"Login successful","content":{"application/json":{"schema":{"type":"object","required":["success","message","auth_session_id","user"],"properties":{"success":{"type":"boolean","example":true},"message":{"type":"string"},"auth_session_id":{"type":"string","format":"uuid","description":"Auth session ID for session tracking"},"analytics_session_id":{"type":["string","null"],"format":"uuid","description":"Analytics session ID for heartbeat tracking. Frontend should send this as x-session-id header on POST /api/analytics/heartbeat."},"user":{"type":"object","required":["id","email","min_rank","max_rank","history_length","role"],"properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string","format":"email"},"min_rank":{"type":"integer"},"max_rank":{"type":"integer"},"history_length":{"type":"integer"},"list_id":{"type":["string","null"],"format":"uuid"},"role":{"type":"string"}}}}},"example":{"success":true,"message":"Login successful","auth_session_id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","analytics_session_id":"f1e2d3c4-b5a6-7890-abcd-ef1234567890","user":{"id":"550e8400-e29b-41d4-a716-446655440000","email":"user@example.com","min_rank":1,"max_rank":20000,"history_length":3,"list_id":null,"role":"user"}}}}},"400":{"description":"Bad Request - Invalid input (Zod validation)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Invalid input","details":[{"code":"custom","message":"Code must be 6 digits","path":["code"]}]}}}},"401":{"description":"Invalid or expired OTP code, or user not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Invalid or expired code"}}}},"429":{"description":"Too Many Requests - Rate limit exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Rate limit exceeded. Try again later."}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to verify code"}}}}},"operationId":"createAuthVerifyOtp"}},"/api/auth/jwt/request-otp":{"post":{"summary":"Request OTP (JWT)","description":"Request a one-time login code. Works for both new and existing users.","tags":["Auth (JWT)"],"security":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmailInput"},"example":{"email":"user@example.com"}}},"required":true},"responses":{"200":{"description":"OTP sent (or message shown to prevent email enumeration)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageResponse"},"example":{"message":"If an account exists with this email, a login code has been sent."}}}},"400":{"description":"Bad Request - Invalid or missing parameters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Invalid request parameters"}}}},"429":{"description":"Too Many Requests - Rate limit exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Rate limit exceeded. Try again later."}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"An unexpected error occurred"}}}}},"operationId":"createV2AuthRequestOtp"}},"/api/auth/jwt/verify-otp":{"post":{"summary":"Verify OTP (JWT)","description":"Verify OTP code. Creates account if user doesn't exist. Returns is_new_user flag.","tags":["Auth (JWT)"],"security":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OtpVerifyInput"},"example":{"email":"user@example.com","code":"12345678"}}},"required":true},"responses":{"200":{"description":"Login successful","content":{"application/json":{"schema":{"type":"object","required":["success","message","access_token","refresh_token","expires_in","is_new_user","user"],"properties":{"success":{"type":"boolean","example":true},"message":{"type":"string"},"access_token":{"type":"string","description":"JWT access token for API authorization"},"refresh_token":{"type":"string","description":"Refresh token for obtaining new access tokens"},"expires_in":{"type":"integer","description":"Access token lifetime in seconds (currently 86400 = 24h). Frontend should refresh at ~1h remaining.","example":86400},"auth_session_id":{"type":"string","format":"uuid","description":"Auth session ID for event tracking"},"analytics_session_id":{"type":["string","null"],"format":"uuid","description":"Analytics session ID for heartbeat tracking. Frontend should send this as x-session-id header on POST /api/analytics/heartbeat."},"is_new_user":{"type":"boolean","description":"True if this OTP verification created a new account (HTTP 201), false for existing user login (HTTP 200)."},"user":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"},"min_rank":{"type":"integer"},"max_rank":{"type":"integer"},"history_length":{"type":"integer"},"list_id":{"type":["string","null"]},"book_id":{"type":["string","null"]},"role":{"type":"string"}}}}},"example":{"success":true,"message":"Login successful","access_token":"eyJhbGciOiJIUzI1NiIs...","refresh_token":"eyJhbGciOiJIUzI1NiIs...","auth_session_id":"7ca8c921-0ebe-22e2-91c5-11d15fd541d9","analytics_session_id":"d4e5f6a7-b8c9-0123-4567-890abcdef012","user":{"id":"550e8400-e29b-41d4-a716-446655440000","email":"user@example.com","min_rank":1,"max_rank":50,"history_length":10,"list_id":null,"role":"user"}}}}},"400":{"description":"Bad Request - Invalid input","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Invalid input"}}}},"401":{"description":"Invalid or expired code","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Invalid or expired code"}}}},"403":{"description":"Forbidden — insufficient permissions or activity limit reached"},"429":{"description":"Too Many Requests - Rate limit exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Rate limit exceeded. Try again later."}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"An unexpected error occurred"}}}}},"operationId":"createV2AuthVerifyOtp"}},"/api/auth/jwt/me":{"get":{"summary":"Get Current User (JWT)","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"},"example":{"user":{"id":"550e8400-e29b-41d4-a716-446655440000","email":"user@example.com","username":"FrostWolfEmber","min_rank":1,"max_rank":50,"history_length":10,"untested_threshold":20,"interest_sort":"overall","list_id":null,"role":"user","word_category":""}}}}},"401":{"description":"Unauthorized - Authentication required or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"404":{"description":"User not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"User not found"}}}},"429":{"description":"Too Many Requests - Rate limit exceeded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Rate limit exceeded. Try again later."}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"An unexpected error occurred"}}}}},"operationId":"getV2AuthMe","tags":["Auth (JWT)"],"description":"Get Current User (JWT). Returns the authenticated user profile."}},"/api/profile/me":{"get":{"summary":"Get Current User Profile","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"},"example":{"user":{"id":"550e8400-e29b-41d4-a716-446655440000","email":"user@example.com","username":"FrostWolfEmber","min_rank":1,"max_rank":50,"history_length":5,"untested_threshold":20,"interest_sort":"overall","list_id":null,"book_id":null,"role":"user","word_category":"","plan":"free"}}}}},"401":{"description":"Unauthorized - Authentication required or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"404":{"description":"User not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"User not found"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch user profile data"}}}}},"operationId":"getProfileMe","tags":["Profile"],"description":"Get Current User Profile. Returns the current user profile."},"patch":{"summary":"Update User Profile","description":"Update learning preferences. `list_id` and `book_id` are mutually exclusive — setting one clears the other. Omitting a field keeps its current value; passing null or empty string clears it.","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"min_rank":{"type":"integer","description":"Sets the lower bound for difficulty filtering to exclude easier words. Accepts an integer from 1 (easiest) to 100."},"max_rank":{"type":"integer","description":"Sets the upper bound for difficulty filtering to exclude harder words. Accepts an integer from 1 to 100 (hardest)."},"history_length":{"type":"integer","minimum":3,"maximum":8,"description":"Number of answer history entries to track (must be between 3 and 8)"},"list_id":{"type":["string","null"],"description":"UUID of a vocab list, or null/empty string to clear. Mutually exclusive with book_id — setting one clears the other."},"book_id":{"type":["string","null"],"description":"UUID of a book list (type='book'), or null/empty string to clear. Mutually exclusive with list_id — setting one clears the other. Target must be a list of type 'book'."},"word_category":{"type":"string","enum":["","verb","noun","adjective","adverb"],"description":"Filter vocabulary by word category. Empty string to clear (all categories)."},"untested_threshold":{"type":"integer","minimum":10,"maximum":30,"description":"Minimum number of untested words in pool before new words are added (10-30)"},"username":{"type":"string","minLength":3,"maxLength":30,"description":"Display name. 3-30 chars, letters/numbers/underscores only, must be unique."},"interest_sort":{"type":"string","enum":["overall","sound","depth","etymology","timelessness","imagery","uniqueness","story","emotion","versatility","precision"],"description":"Which interest dimension to sort the test word pool by."}}},"example":{"min_rank":1,"max_rank":50,"history_length":5,"untested_threshold":25,"interest_sort":"etymology","list_id":"550e8400-e29b-41d4-a716-446655440000","word_category":"noun"}}},"required":true},"responses":{"200":{"description":"Profile updated successfully","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/UserResponse"},{"type":"object","properties":{"success":{"type":"boolean","example":true}},"required":["success"]}]},"example":{"success":true,"user":{"id":"550e8400-e29b-41d4-a716-446655440000","email":"user@example.com","username":"FrostWolfEmber","min_rank":1,"max_rank":50,"history_length":5,"untested_threshold":25,"interest_sort":"etymology","list_id":null,"book_id":null,"role":"user","word_category":"noun"}}}}},"400":{"description":"Bad Request - Invalid parameters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"history_length must be between 3 and 8"}}}},"401":{"description":"Unauthorized - Authentication required or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Forbidden - Access denied to the specified userlist","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Access denied to the specified userlist"}}}},"404":{"description":"User not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"User not found"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to update profile"}}}}},"operationId":"updateProfileMe","tags":["Profile"]}},"/api/auth/jwt/refresh":{"post":{"summary":"Refresh Token","description":"Refresh the JWT access token using a refresh token. Rate limited to 30 requests per hour.","security":[],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["refresh_token","access_token"],"properties":{"refresh_token":{"type":"string","description":"Used to obtain a new access token without re-authenticating. Accepts the refresh token string returned from login or previous refresh."},"access_token":{"type":"string","description":"The current (possibly expired) access token. Used to verify user ownership and revoke (blacklist its jti) during rotation."}}},"example":{"refresh_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...","access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}}},"required":true},"responses":{"200":{"description":"New tokens issued successfully","content":{"application/json":{"schema":{"type":"object","required":["success","access_token","refresh_token"],"properties":{"success":{"type":"boolean","example":true},"access_token":{"type":"string","description":"New JWT access token"},"refresh_token":{"type":"string","description":"New refresh token (old one is revoked)"},"expires_in":{"type":"integer","description":"Access token lifetime in seconds","example":86400}}},"example":{"success":true,"access_token":"eyJhbGciOiJIUzI1NiIs...","refresh_token":"eyJhbGciOiJIUzI1NiIs...","expires_in":86400}}}},"400":{"description":"Bad Request - Missing refresh token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Refresh token is required"}}}},"401":{"description":"Unauthorized - Invalid or expired refresh token, or user not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Invalid or expired refresh token"}}}},"429":{"description":"Too many requests (limit: 120 per hour)"},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to refresh token"}}}}},"operationId":"createV2AuthRefresh","tags":["Auth (JWT)"]}},"/api/vocabulary":{"post":{"summary":"Add Word to Dictionary","description":"Allows a user to add a new word to the vocabulary dictionary. Includes multi-layered validation (length, alpha-only, profanity, and English dictionary check). Limited to 50 requests per 15 minutes.","tags":["Vocabulary"],"operationId":"createVocabularyWord","security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["word"],"additionalProperties":false,"properties":{"word":{"type":"string","minLength":3,"maxLength":20,"description":"The word to add (3-20 alphabetical characters, no spaces)"}}},"example":{"word":"ephemeral"}}}},"responses":{"200":{"description":"Word added successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageResponse"},"example":{"message":"Word added successfully"}}}},"400":{"description":"Invalid input format, extra fields, or word validation failure","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Only 'word' field is allowed."}}}},"401":{"description":"Unauthorized - Authentication required or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"409":{"description":"Word already exists in dictionary","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Word already exists in dictionary"}}}},"429":{"description":"Too many requests - rate limit exceeded (50 per 15 minutes)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Too many requests, please try again later."}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to add word"}}}}}}},"/api/admin/vocabulary/external-search":{"get":{"summary":"External Dictionary Prefix Search","description":"Searches for English words starting with a specific prefix using the Datamuse API. Useful for autocomplete/suggestions.","operationId":"getAdminVocabularyExternalSearch","tags":["Admin"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"prefix","in":"query","description":"Prefix to search for (minimum 1 character)","required":true,"schema":{"type":"string","minLength":1}}],"responses":{"200":{"description":"List of matching words from external dictionary","content":{"application/json":{"schema":{"type":"object","required":["words"],"properties":{"words":{"type":"array","items":{"type":"string"}}}},"example":{"words":["test","testify","testing","testament"]}}}},"400":{"description":"Missing or invalid prefix","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"prefix must be at least 1 character"}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Forbidden - Admin access required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Admin access required"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to search external dictionary"}}}}},"x-requires-admin":true}},"/api/admin/vocabulary/parse-text":{"post":{"summary":"Parse Text for Words","description":"Extract words from free-form text and add new ones to the vocabulary. Filters profanity, enforces 3-20 character length, deduplicates against existing vocabulary. New words are inserted with the specified rank as difficulty (default 101 = unranked). Existing words found in the text have their last_updated timestamp refreshed.","operationId":"createAdminVocabularyParseText","tags":["Admin"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["text"],"properties":{"text":{"type":"string","maxLength":50000,"description":"Free-form text to extract words from"},"rank":{"type":"integer","minimum":1,"maximum":100,"description":"Difficulty rank to assign to new words (1-100). Omit for default 101 (unranked)."}}},"example":{"text":"The ephemeral nature of serendipity and melancholy creates a bittersweet symphony","rank":42}}}},"responses":{"200":{"description":"Words extracted and processed","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"existing":{"type":"integer","description":"Number of words already in vocabulary"},"added":{"type":"integer","description":"Number of new words inserted"},"words":{"type":"array","items":{"type":"string"},"description":"All valid words extracted from the text (both existing and newly added)"}}},"example":{"success":true,"existing":7,"added":2,"words":["the","ephemeral","nature","serendipity","and","melancholy","creates","bittersweet","symphony"]}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"text is required"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin access required"},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to parse text"}}}}},"x-requires-admin":true}},"/api/admin/vocabulary/bulk-import":{"post":{"summary":"Bulk Import Vocabulary (Search & Import)","description":"Polls an external dictionary for words matching the prefix, validates them, and imports them into the local database. Difficulty (1-100) is automatically computed from Google Ngram frequency using a log-scale formula. Higher frequency words get lower difficulty scores. Words with no frequency data default to difficulty 50. Tuning parameters (min_log, max_log) are read from vocab.settings.json.","operationId":"createAdminVocabularyBulkImport","tags":["Admin"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["wordsearch"],"properties":{"wordsearch":{"type":"string","description":"Prefix to search for words to import (1-50 characters)","minLength":1,"maxLength":50},"pos":{"type":"string","description":"Optional part of speech filter","enum":["noun","adjective","verb","adverb"]},"limit":{"type":"integer","description":"Maximum number of words to import (default 50, clamped 1-1000)","default":50,"minimum":1,"maximum":1000},"min_frequency":{"type":"number","description":"Minimum frequency threshold (per million words). Must be >= 0."}}},"example":{"wordsearch":"test","pos":"noun","limit":50,"min_frequency":10}}}},"responses":{"200":{"description":"Bulk import results","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"created_words":{"type":"array","items":{"type":"string"}},"updated_words":{"type":"array","items":{"type":"string"}},"exists":{"type":"array","items":{"type":"string"},"description":"Words that already exist in the database"},"errors":{"type":"array","items":{"type":"string"}}}},"example":{"success":true,"created_words":["testify","testament"],"updated_words":[],"exists":["test"],"errors":[]}}}},"400":{"description":"Invalid or missing parameters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"wordsearch must be a string of 1-50 characters"}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Forbidden - Admin access required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Admin access required"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to bulk import vocabulary"}}}}},"x-requires-admin":true}},"/api/quotes":{"post":{"summary":"Add Quote","description":"Add a new quote to the system (Admin only)","tags":["Quotes"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuoteInput"},"example":{"quote":"The only way to do great work is to love what you do.","author":"Steve Jobs"}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"type":"object","required":["id","words_added"],"properties":{"id":{"type":"string"},"words_added":{"type":"integer"},"rejected":{"type":"boolean"}}},"example":{"id":"550e8400-e29b-41d4-a716-446655440000","words_added":7}}}},"400":{"description":"Bad Request - Validation error (quote/author required, length limits)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Quote must be at least 10 characters"}}}},"401":{"description":"Unauthorized - Authentication required or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Forbidden - Admin access required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Admin access required"}}}},"409":{"description":"Conflict — resource already exists or invalid state"},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to add quote"}}}}},"operationId":"createQuote","x-requires-admin":true},"get":{"summary":"Get Quote(s)","description":"Retrieve a quote by word ID, quote ID, or the last 20 quotes if no filters are provided. When using word_id, pass limit to control how many quotes are returned (default 1, max 100). With limit=1, returns a single object; with limit>1, returns an array. Results are ordered: real quotes first, then examples, randomized within each group.","tags":["Quotes"],"parameters":[{"name":"word_id","in":"query","required":false,"description":"UUID of the word.","schema":{"type":"string","format":"uuid"}},{"name":"quote_id","in":"query","required":false,"description":"UUID of the quote.","schema":{"type":"string","format":"uuid"}},{"name":"limit","in":"query","required":false,"description":"Number of quotes to return when using word_id. Default 1, max 100. With limit=1 returns a single object; limit>1 returns an array.","schema":{"type":"integer","minimum":1,"maximum":100,"default":1}}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"oneOf":[{"type":"object","properties":{"id":{"type":"string"},"quote":{"type":"string"},"author":{"type":["string","null"]},"source":{"type":"string"},"quote_type":{"type":"string","enum":["quote","example"]},"quote_parsed":{"type":"boolean"},"dateadded":{"type":"string","format":"date-time"}}},{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"quote":{"type":"string"},"author":{"type":["string","null"]},"source":{"type":"string"},"quote_type":{"type":"string","enum":["quote","example"]},"quote_parsed":{"type":"boolean"},"dateadded":{"type":"string","format":"date-time"}}}}]},"example":{"id":"550e8400-e29b-41d4-a716-446655440000","quote":"The only way to do great work...","author":"Steve Jobs","quote_type":"quote"}}}},"401":{"description":"Unauthorized - Authentication required or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"404":{"description":"Quote not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Quote not found"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to get quote"}}}}},"operationId":"getQuotes"}},"/api/quotes/{id}/parse":{"post":{"summary":"Parse Quote","description":"Parse a quote into vocabulary words and link them via quote_details (Admin only)","tags":["Quotes"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Quote UUID"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"},"quote":{"type":"object","properties":{"id":{"type":"string"},"quote":{"type":"string"},"author":{"type":["string","null"]},"source":{"type":"string"},"quote_parsed":{"type":"boolean"},"quote_type":{"type":"string"},"dateadded":{"type":"string","format":"date-time"}}}}},"example":{"message":"Quote parsed successfully","quote":{"id":"550e8400-e29b-41d4-a716-446655440000","quote":"The only way to do great work is to love what you do.","author":"Steve Jobs","source":"","quote_parsed":true,"quote_type":"quote","dateadded":"2025-01-15T12:00:00.000Z"}}}}},"400":{"description":"quoteId is required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"quote_id is required"}}}},"401":{"description":"Unauthorized - Authentication required or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Forbidden - Admin access required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Admin access required"}}}},"404":{"description":"Quote not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Quote not found"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to parse quote"}}}}},"operationId":"parseQuote","x-requires-admin":true}},"/api/admin/vocabulary":{"get":{"summary":"List Vocabulary (Ordered by Last Added)","description":"Paginated vocabulary list with sorting and filtering. Admin only.","operationId":"getAdminVocabulary","tags":["Admin"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"$ref":"#/components/parameters/page"},{"$ref":"#/components/parameters/limit"},{"name":"has_quote","in":"query","description":"Filter by whether the word has an associated quote","required":false,"schema":{"type":"boolean"}},{"name":"search","in":"query","required":false,"schema":{"type":"string"},"description":"Filters vocabulary by matching against word text only (ILIKE). Partial matches supported."},{"name":"definition_search","in":"query","required":false,"schema":{"type":"string"},"description":"Filters vocabulary by matching against definition text (ILIKE). Partial matches supported."},{"name":"r2_audio","in":"query","required":false,"schema":{"type":"boolean"},"description":"Filter by whether audio has been migrated to R2 storage (true) or still served from bytea column (false)."},{"$ref":"#/components/parameters/min_rank"},{"$ref":"#/components/parameters/max_rank"},{"name":"has_audio","in":"query","required":false,"schema":{"type":"boolean"},"description":"Filter by audio availability"},{"name":"audio_confirmed","in":"query","required":false,"schema":{"type":"boolean"},"description":"Filter by audio quality verification status"},{"name":"has_error","in":"query","required":false,"description":"Filter by error status: true (has error), false (no error), null (all)","schema":{"type":["boolean","null"]}},{"name":"has_definition","in":"query","required":false,"description":"Filter by definition status: true (has definition), false (no definition), null (all)","schema":{"type":["boolean","null"]}},{"name":"deprecated","in":"query","required":false,"description":"Filter by deprecated status: true (deprecated), false (not deprecated), null (all)","schema":{"type":["boolean","null"]}},{"name":"starred","in":"query","required":false,"description":"Filter by starred status: true (starred only), false (unstarred only), omit for all","schema":{"type":"boolean"}},{"name":"category","in":"query","required":false,"description":"Filter by word category (partial match). Use 'All' or omit for no filtering. Use 'Empty' to return words with null/empty categories.","schema":{"type":"string","enum":["All","Empty","noun","adjective","verb","adverb"]}},{"name":"include_audio","in":"query","required":false,"description":"Include base64-encoded audio data in the response. Caution: significantly increases payload size.","schema":{"type":"boolean","default":false}},{"name":"public_ready","in":"query","required":false,"description":"Filter by public_ready status: true (ready for public display), false (not ready), omit for all","schema":{"type":"boolean"}},{"name":"flagged_invalid","in":"query","required":false,"description":"Filter by flagged_invalid status: true (word not found in dictionary during enrichment), false (valid or not yet checked), omit for all","schema":{"type":"boolean"}},{"name":"on_userlist","in":"query","required":false,"description":"Filter by userlist membership: true (word is on any userlist), false (word is not on any userlist), omit for all","schema":{"type":"boolean"}},{"name":"needs_cleaning","in":"query","required":false,"description":"Filter for userlist words missing enrichment: audio, definitions (<3), examples (<3), etymology, interest score, or pronunciation","schema":{"type":"boolean"}},{"name":"last_updated_from","in":"query","required":false,"description":"Filter by last_updated >= this date (e.g. 2026-01-01)","schema":{"type":"string","format":"date"}},{"name":"last_updated_to","in":"query","required":false,"description":"Filter by last_updated <= this date (e.g. 2026-03-01)","schema":{"type":"string","format":"date"}},{"name":"sort_by","in":"query","description":"Field to sort by","required":false,"schema":{"type":"string","enum":["word","difficulty","definition","category","last_updated"],"default":"last_updated"}},{"name":"sort_direction","in":"query","description":"Sort direction","required":false,"schema":{"type":"string","enum":["ASC","DESC"],"default":"DESC"}}],"responses":{"200":{"description":"Paginated vocabulary list","content":{"application/json":{"schema":{"type":"object","required":["items","total","page","limit","total_pages"],"properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"word":{"type":"string"},"difficulty":{"type":"integer"},"audio_mime_type":{"type":["string","null"]},"audio_exists":{"type":"boolean"},"audio_confirmed":{"type":"boolean"},"report_error":{"type":"string","description":"Error report text, empty string if none"},"has_definition":{"type":"boolean"},"deprecated":{"type":"boolean"},"definition":{"type":["string","null"]},"definition_count":{"type":"integer","description":"Number of definitions for this word"},"category":{"type":"string"},"last_updated":{"type":"string","format":"date-time"},"date":{"type":"string","format":"date-time"},"date_created":{"type":"string","format":"date-time","description":"Immutable creation timestamp"},"audio_data":{"type":["string","null"],"description":"Always null in list response"},"example_quote_id":{"type":["string","null"],"format":"uuid"},"example_quote_last_updated":{"type":"string","format":"date-time"},"frequency":{"type":"string"},"starred":{"type":"boolean"},"audio_altered":{"type":"boolean","description":"Whether audio has been re-saved after initial generation"},"audio_duration":{"type":"integer","nullable":true,"description":"Audio clip duration in milliseconds"},"voice":{"type":["string","null"],"description":"TTS voice used to generate audio (e.g. 'alloy', 'nova')"},"r2_audio":{"type":"boolean","nullable":true,"description":"R2 audio status: true = on R2, false = failed, null = local"}}}},"total":{"type":"integer"},"page":{"type":"integer"},"limit":{"type":"integer"},"total_pages":{"type":"integer"}}},"example":{"items":[{"id":"550e8400-e29b-41d4-a716-446655440000","word":"ephemeral","difficulty":75,"audio_mime_type":"audio/wav","audio_exists":true,"audio_confirmed":true,"report_error":"","has_definition":true,"deprecated":false,"definition":"Lasting for a very short time","category":"","last_updated":"2026-02-01T12:00:00.000Z","date":"2026-01-15T12:00:00.000Z","date_created":"2026-01-15T12:00:00.000Z","audio_data":null,"example_quote_id":null,"example_quote_last_updated":"2026-02-01T12:00:00.000Z","frequency":"5.2","audio_altered":false}],"total":1,"page":1,"limit":50,"total_pages":1}}}},"400":{"description":"Missing or invalid pagination/sort parameters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"page and limit query parameters are required"}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Forbidden - Admin access required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Admin access required"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch vocabulary"}}}}},"x-requires-admin":true},"post":{"summary":"Create Word","description":"Create a new vocabulary word with validation. Rejects duplicates (case-insensitive).","operationId":"createAdminVocabulary","tags":["Admin"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["word"],"properties":{"word":{"type":"string","description":"The vocabulary term (2-50 characters, letters only, no spaces or special characters)"},"difficulty":{"type":"integer","description":"Difficulty rank from 1 (beginner) to 100 (advanced). Default: 50","minimum":1,"maximum":101},"definition":{"type":"string","description":"Word definition, up to 2000 characters","maxLength":2000},"error_message":{"type":"string","description":"Optional error message to report for this word"}}},"example":{"word":"ubiquitous","difficulty":65,"definition":"Present, appearing, or found everywhere"}}}},"responses":{"201":{"description":"Word created successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"id":{"type":"string","format":"uuid"},"word":{"type":"string"},"difficulty":{"type":"integer"},"audio_mime_type":{"type":["string","null"]},"audio_exists":{"type":"boolean"},"audio_confirmed":{"type":"boolean"},"report_error":{"type":"string"},"has_definition":{"type":"boolean"},"deprecated":{"type":"boolean"},"definition":{"type":["string","null"]},"definition_count":{"type":"integer","description":"Number of definitions for this word"},"last_updated":{"type":"string","format":"date-time"},"date":{"type":"string","format":"date-time"},"date_created":{"type":"string","format":"date-time","description":"Immutable creation timestamp"},"audio_data":{"type":["string","null"]},"example_quote_id":{"type":["string","null"],"format":"uuid"},"frequency":{"type":"number"},"audio_altered":{"type":"boolean"},"audio_duration":{"type":"integer","nullable":true,"description":"Audio clip duration in milliseconds"},"r2_audio":{"type":"boolean","nullable":true,"description":"R2 audio status: true = on R2, false = failed, null = local"}}},"example":{"success":true,"id":"550e8400-e29b-41d4-a716-446655440000","word":"ubiquitous","difficulty":65,"audio_mime_type":null,"audio_exists":false,"audio_confirmed":false,"report_error":"","has_definition":true,"deprecated":false,"definition":"Present, appearing, or found everywhere","last_updated":"2026-02-01T12:00:00.000Z","date":"2026-02-01T12:00:00.000Z","date_created":"2026-02-01T12:00:00.000Z","audio_data":null,"example_quote_id":null,"frequency":0,"audio_altered":false}}}},"400":{"description":"Validation error (invalid characters, length, etc.)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Word must be 2-50 characters"}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Forbidden - Admin access required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Admin access required"}}}},"409":{"description":"Conflict - Word already exists","content":{"application/json":{"schema":{"type":"object","required":["error","existing_word"],"properties":{"error":{"type":"string"},"existing_word":{"type":"object","description":"The existing vocabulary word object"}}},"example":{"error":"Word already exists in dictionary","existing_word":{"id":"550e8400-e29b-41d4-a716-446655440000","word":"ubiquitous"}}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to create word"}}}}},"x-requires-admin":true}},"/api/admin/vocabulary/checkword":{"get":{"summary":"Check if Word Exists","description":"Check if a word already exists in the vocabulary database (case-insensitive). Admin only.","operationId":"getAdminVocabularyCheckword","tags":["Admin"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"word","in":"query","required":true,"description":"The word to check (trimmed and lowercased before lookup)","schema":{"type":"string"}}],"responses":{"200":{"description":"Existence status and ID if found","content":{"application/json":{"schema":{"type":"object","required":["exists"],"properties":{"exists":{"type":"boolean"},"id":{"type":"string","format":"uuid","description":"Present only when exists is true"}}},"example":{"exists":true,"id":"550e8400-e29b-41d4-a716-446655440000"}}}},"400":{"description":"Missing word parameter","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"word query parameter is required"}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Forbidden - Admin access required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Admin access required"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Internal Server Error"}}}}},"x-requires-admin":true}},"/api/admin/vocabulary/{id}":{"get":{"summary":"Get Word by ID","description":"Fetch a single vocabulary word with all fields. Admin only.","operationId":"getAdminVocabularyByid","tags":["Admin"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"$ref":"#/components/parameters/id"}],"responses":{"200":{"description":"Full vocabulary word object","content":{"application/json":{"schema":{"type":"object","properties":{"audio_altered":{"type":"boolean"},"audio_confirmed":{"type":"boolean"},"audio_exists":{"type":"boolean"},"audio_mime_type":{"type":["string","null"]},"category":{"type":"string"},"date_created_string":{"type":"string","description":"Immutable creation timestamp"},"date_string":{"type":"string"},"definition":{"type":["string","null"]},"definition_count":{"type":"integer","description":"Number of definitions for this word"},"deprecated":{"type":"boolean"},"dictionary_data":{"type":"object","nullable":true,"description":"Enrichment data from Free Dictionary API + AI etymology","properties":{"antonyms":{"type":"array","items":{"type":"string"}},"etymology":{"type":"string","nullable":true},"pronunciation":{"type":"string","nullable":true},"synonyms":{"type":"array","items":{"type":"string"}},"word_forms":{"type":"array","items":{"type":"object","properties":{"tags":{"type":"array","items":{"type":"string"}},"word":{"type":"string"}}}}}},"dictionary_last_parsed_string":{"type":["string","null"],"description":"When enrichment last ran"},"difficulty":{"type":"integer"},"example_quote_id":{"type":["string","null"],"format":"uuid"},"flagged_invalid":{"type":"boolean","description":"Word not found in dictionary during enrichment"},"frequency":{"type":"number"},"has_definition":{"type":"boolean"},"id":{"type":"string","format":"uuid"},"last_updated_string":{"type":"string"},"public_ready":{"type":"boolean","description":"Word has enough data for public display"},"r2_audio":{"type":"boolean","nullable":true,"description":"R2 audio status: true = on R2, false = failed, null = local"},"report_error":{"type":"string"},"starred":{"type":"boolean"},"voice":{"type":["string","null"],"description":"TTS voice used to generate audio"},"word":{"type":"string"}}},"example":{"audio_altered":false,"audio_confirmed":true,"audio_exists":true,"audio_mime_type":"audio/wav","category":"adjective","date_created_string":"2026-01-15 12:00:00","date_string":"2026-01-15 12:00:00","definition":"Lasting for a very short time","definition_count":3,"deprecated":false,"dictionary_data":{"antonyms":["permanent","enduring"],"etymology":"The word ephemeral traces its roots to the Greek ephēmeros...","pronunciation":"/ɪˈfɛm.ər.əl/","synonyms":["fleeting","transient","short-lived"],"word_forms":[{"tags":["comparative"],"word":"more ephemeral"},{"tags":["superlative"],"word":"most ephemeral"}]},"dictionary_last_parsed_string":"2026-03-30 06:31:03","difficulty":75,"example_quote_id":null,"flagged_invalid":false,"frequency":5.2,"has_definition":true,"id":"550e8400-e29b-41d4-a716-446655440000","last_updated_string":"2026-02-01 12:00:00","public_ready":true,"r2_audio":true,"report_error":"","starred":false,"voice":"alloy","word":"ephemeral"}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Forbidden - Admin access required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Admin access required"}}}},"404":{"description":"Word not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Word not found"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch word"}}}}},"x-requires-admin":true},"patch":{"summary":"Update Word","description":"Update one or more fields of a vocabulary word. Returns the full updated word object. Admin only.","operationId":"updateAdminVocabularyByid","tags":["Admin"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"$ref":"#/components/parameters/id"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"properties":{"difficulty":{"type":"integer","description":"Difficulty rank from 1 to 101","minimum":1,"maximum":101},"audio_confirmed":{"type":"boolean","description":"Mark audio as verified"},"deprecated":{"type":"boolean","description":"Hide word from tests and searches"},"flagged_invalid":{"type":"boolean","description":"Flag word as not a valid English word"},"report_error":{"type":"string","description":"Admin note about the error"},"error_message":{"type":"string","description":"Error description"},"example_quote_id":{"type":["string","null"],"format":"uuid","description":"UUID of existing quote, or null to remove"},"frequency":{"type":"number","description":"Word frequency score"},"starred":{"type":"boolean","description":"Star/unstar word for admin review"},"public_ready":{"type":"boolean","description":"Mark word as ready for public display"}}},"example":{"difficulty":70,"audio_confirmed":true,"deprecated":false,"public_ready":true}}}},"responses":{"200":{"description":"Updated vocabulary word object (same shape as GET by ID)","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"id":{"type":"string","format":"uuid"},"word":{"type":"string"},"difficulty":{"type":"integer"},"audio_mime_type":{"type":["string","null"]},"audio_exists":{"type":"boolean"},"audio_confirmed":{"type":"boolean"},"report_error":{"type":"string"},"has_definition":{"type":"boolean"},"deprecated":{"type":"boolean"},"definition":{"type":["string","null"]},"definition_count":{"type":"integer","description":"Number of definitions for this word"},"last_updated":{"type":"string","format":"date-time"},"date":{"type":"string","format":"date-time"},"date_created":{"type":"string","format":"date-time","description":"Immutable creation timestamp"},"audio_data":{"type":["string","null"]},"example_quote_id":{"type":["string","null"],"format":"uuid"},"frequency":{"type":"number"},"audio_altered":{"type":"boolean"},"audio_duration":{"type":"integer","nullable":true,"description":"Audio clip duration in milliseconds"},"r2_audio":{"type":"boolean","nullable":true,"description":"R2 audio status: true = on R2, false = failed, null = local"}}},"example":{"success":true,"id":"550e8400-e29b-41d4-a716-446655440000","word":"ephemeral","difficulty":70,"audio_mime_type":"audio/wav","audio_exists":true,"audio_confirmed":true,"report_error":"","has_definition":true,"deprecated":false,"definition":"Updated definition for this word","last_updated":"2026-02-11T12:00:00.000Z","date":"2026-01-15T12:00:00.000Z","date_created":"2026-01-15T12:00:00.000Z","audio_data":null,"example_quote_id":null,"frequency":5.2,"audio_altered":false}}}},"400":{"description":"Invalid definition length","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Definition must be a string of 2000 characters or less"}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Forbidden - Admin access required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Admin access required"}}}},"404":{"description":"Word not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Word not found"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to update word"}}}}},"x-requires-admin":true},"delete":{"summary":"Delete Word","description":"Delete a vocabulary word and its related test records. Admin only.","operationId":"deleteAdminVocabularyByid","tags":["Admin"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"$ref":"#/components/parameters/id"}],"responses":{"200":{"description":"Word deleted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageResponse"},"example":{"message":"Word deleted successfully"}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Forbidden - Admin access required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Admin access required"}}}},"404":{"description":"Vocabulary word not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Vocabulary word not found"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to delete word"}}}}},"x-requires-admin":true}},"/api/test/vocab/getword/spelling":{"get":{"summary":"Get Spelling Word","description":"Retrieve a word for the spelling test. Uses user's minRank/maxRank and optional userlist for filtering.","operationId":"getTestGetwordSpelling","tags":["Tests"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"$ref":"#/components/parameters/testIdQuery"}],"responses":{"200":{"description":"Word with progress data for spelling test","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestWordResponse"},"example":{"word_id":"123e4567-e89b-12d3-a456-426614174000","test_id":"550e8400-e29b-41d4-a716-446655440000","mode":"spelling","word":{"id":"123e4567-e89b-12d3-a456-426614174000","word":"aberrant","definition":"departing from an accepted standard","difficulty":75,"date":"2026-01-19T12:00:00Z","answer_score":0.85,"times_tested":10,"win_streak":3,"lose_streak":0,"answer_history":[1,1,0,1]}}}}},"400":{"description":"Missing or invalid testId","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"test_id is required"}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Forbidden — insufficient permissions or activity limit reached"},"404":{"description":"No words available or word not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"No words available"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch word"}}}}}}},"/api/test/vocab/getword/getnextwords":{"get":{"summary":"Get Next Words Preview","description":"Preview the next eligible words that will be added to the user's test pool across all modes. Returns a deterministic list ordered by word ID, filtered by the user's current settings (difficulty range, userlist, category). Excludes words already in any test mode.","operationId":"getTestGetwordGetnextwords","tags":["Tests"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":10,"maximum":50,"default":20},"description":"Number of words to return (clamped to 10-50)"}],"responses":{"200":{"description":"List of next eligible words","content":{"application/json":{"schema":{"type":"object","properties":{"words":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"word":{"type":"string"},"definition":{"type":"string","nullable":true}}}},"count":{"type":"integer"}}},"example":{"words":[{"id":"123e4567-e89b-12d3-a456-426614174000","word":"aberrant","definition":"departing from an accepted standard"},{"id":"234e5678-e89b-12d3-a456-426614174001","word":"ephemeral","definition":"lasting for a very short time"}],"count":2}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch next words"}}}}}}},"/api/test/book/getquestion":{"get":{"summary":"Get Next Book Question","description":"Returns the next question from one or more chapters. Tops up pools for all specified chapters (drip-feed + safety-net) then picks the best available question across the set. Send one chapter_id for single-chapter mode, or multiple comma-separated chapter_ids to draw from several chapters (e.g. all unlocked chapters in a book). Optional cooldown overrides the default 30s between re-serving the same question.","operationId":"getTestBookGetquestion","tags":["Tests"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"chapter_ids","in":"query","required":true,"schema":{"type":"string"},"description":"One or more chapter UUIDs, comma-separated. Pools are topped up for all chapters; the next question is picked from the combined set. Also accepts chapter_id (single) for backward compatibility."},{"name":"cooldown","in":"query","schema":{"type":"integer","minimum":0,"maximum":300},"description":"Optional. Seconds before a question can be re-served (default 30, clamped 0-300). Set to 0 to disable cooldown."}],"responses":{"200":{"description":"Self-contained test item with full question, excerpt, and progress data","content":{"application/json":{"schema":{"type":"object","required":["question","excerpt","answer_score","times_tested","win_streak","lose_streak","answer_history"],"properties":{"question":{"type":"object","required":["id","user_id","excerpt_id","question","answer","source_sentences","distractors","r2_audio","sort_order","date_created"],"properties":{"id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"excerpt_id":{"type":"string","format":"uuid"},"question":{"type":"string"},"answer":{"type":"string"},"source_sentences":{"type":"array","items":{"type":"integer"},"description":"1-based sentence indices from the excerpt that the answer derives from."},"distractors":{"type":"array","items":{"type":"string"},"description":"Up to 7 plausible but incorrect alternative answers for multiple-choice presentation."},"is_glossary":{"type":"boolean","description":"Whether this is a glossary question."},"is_extended":{"type":"boolean","description":"Whether this question uses knowledge beyond the excerpt text (extra credit)."},"r2_audio":{"type":"boolean","nullable":true,"description":"TTS audio state: true = on R2, false = failed/deleted, null = not yet attempted."},"sort_order":{"type":"integer"},"date_created":{"type":"string","format":"date-time"}}},"excerpt":{"type":"object","required":["id","user_id","list_id","text","text_sentences","audio_url","image_url","video_url","show_excerpt","is_glossary","r2_audio","sort_order","date_created","questions_count"],"properties":{"id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"list_id":{"type":"string","format":"uuid"},"text":{"type":"string","description":"Excerpt body text."},"text_sentences":{"type":"array","items":{"type":"string"},"description":"Text split into sentences. Indices match source_sentences on the question."},"audio_url":{"type":"string"},"image_url":{"type":"string"},"video_url":{"type":"string"},"show_excerpt":{"type":"boolean","description":"Whether to display this excerpt's text during testing."},"is_glossary":{"type":"boolean","description":"Whether this is a glossary excerpt."},"r2_audio":{"type":"boolean","nullable":true,"description":"TTS audio state: true = on R2, false = failed/deleted, null = not yet attempted."},"sort_order":{"type":"integer"},"date_created":{"type":"string","format":"date-time"},"questions_count":{"type":"integer"}}},"answer_score":{"type":"number","description":"Current mastery score (0–1)."},"times_tested":{"type":"integer","description":"Total number of times this question has been tested."},"win_streak":{"type":"integer","description":"Current consecutive correct answers."},"lose_streak":{"type":"integer","description":"Current consecutive incorrect answers."},"answer_history":{"type":"array","items":{"type":"number"},"description":"Recent answer scores (0 or 1), newest last."}}},"example":{"question":{"id":"8d1e2c34-1234-4abc-9876-1234567890ab","user_id":"a1b2c3d4-5566-4778-8899-aabbccddeeff","excerpt_id":"5f2b9a01-9876-4def-1234-fedcba987654","question":"What is the capital of France?","answer":"Paris","source_sentences":[1,3],"distractors":["London","Berlin","Madrid","Rome","Brussels","Amsterdam","Vienna"],"sort_order":1000,"date_created":"2026-05-04T07:42:11.123Z"},"excerpt":{"id":"5f2b9a01-9876-4def-1234-fedcba987654","user_id":"a1b2c3d4-5566-4778-8899-aabbccddeeff","list_id":"5c843ee2-11ba-4e2d-a300-99a9e1f31520","text":"Paris is the capital and most populous city of France.","text_sentences":["Paris is the capital and most populous city of France."],"audio_url":"","image_url":"","video_url":"","show_excerpt":true,"r2_audio":null,"sort_order":1000,"date_created":"2026-05-04T07:42:11.123Z","questions_count":3},"answer_score":0.4,"times_tested":2,"win_streak":0,"lose_streak":1,"answer_history":[1,0]}}}},"400":{"description":"Missing or invalid chapter_id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"chapter_id must be a valid UUID"}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Forbidden — activity gate (free-tier limit reached)"},"404":{"description":"Chapter not accessible, or no questions available (chapter empty / fully mastered with nothing currently due for review)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"no_access":{"value":{"error":"Chapter not found or not accessible"}},"exhausted":{"value":{"error":"No questions available in this chapter"}}}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch question"}}}}}}},"/api/test/book/review-count":{"get":{"summary":"Count questions due for spaced review","description":"Returns the number of mastered questions (answer_score = 1) whose review interval has elapsed based on win_streak and history_length. Use to show a review badge or prompt in the UI.","operationId":"getBookReviewCount","tags":["Tests"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"chapter_ids","in":"query","required":true,"schema":{"type":"string"},"description":"One or more chapter UUIDs, comma-separated. Also accepts chapter_id (single) for backward compatibility."}],"responses":{"200":{"description":"Review due count","content":{"application/json":{"schema":{"type":"object","properties":{"review_due":{"type":"integer","description":"Number of mastered questions past their review interval"}}}}}},"400":{"description":"Missing or invalid chapter_ids"},"404":{"description":"Chapter not found or not accessible"}}}},"/api/test/book/excerpts":{"get":{"summary":"Get Chapter Excerpts (User Read View)","description":"Returns all excerpts attached to a chapter, ordered by sort_order ASC, so the frontend can stitch them into continuous prose for users to read before answering questions. Same access model as /api/test/book/getquestion: the user must own the chapter, have linked it directly via list_access, or have linked the parent book. The admin-only mutation endpoints (POST/PATCH/DELETE on /api/excerpts) are unaffected; this is a separate user-facing read surface.","operationId":"getTestBookExcerpts","tags":["Tests"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"chapter_id","in":"query","required":true,"schema":{"type":"string","format":"uuid"},"description":"UUID of the chapter list whose excerpts to return."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":100,"default":20},"description":"Maximum number of excerpts to return (clamped 1–100, default 20)."},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0},"description":"Number of excerpts to skip (default 0)."},{"name":"excerpt_id","in":"query","required":false,"schema":{"type":"string","format":"uuid"},"description":"Filter to a single excerpt by ID. When provided, limit/offset are ignored and the response contains only the matched excerpt (or 404 if it doesn't belong to this chapter)."}],"responses":{"200":{"description":"Paginated excerpts ordered by sort_order ASC","content":{"application/json":{"schema":{"type":"object","required":["items","total","limit","offset"],"properties":{"items":{"type":"array","items":{"type":"object","required":["id","user_id","list_id","text","text_sentences","audio_url","image_url","video_url","show_excerpt","r2_audio","sort_order","date_created","questions_count"],"properties":{"id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid","description":"Author of the chapter (admin who created it)."},"list_id":{"type":"string","format":"uuid","description":"Chapter list this excerpt belongs to."},"text":{"type":"string","description":"Excerpt body text. Empty string when the excerpt is media-only."},"text_sentences":{"type":"array","items":{"type":"string"},"description":"Text split into sentences. Sentence indices match source_sentences on questions."},"audio_url":{"type":"string","description":"URL to an audio file (MP3/WAV/etc.) for this excerpt. Empty string when not set."},"image_url":{"type":"string","description":"URL to an image for this excerpt. Empty string when not set."},"video_url":{"type":"string","description":"URL to a video for this excerpt. Empty string when not set."},"show_excerpt":{"type":"boolean","description":"Whether to display this excerpt's text during testing. Defaults to true."},"r2_audio":{"type":"boolean","nullable":true,"description":"TTS audio state: true = on R2, false = failed/deleted, null = not yet attempted."},"sort_order":{"type":"integer"},"date_created":{"type":"string","format":"date-time"},"questions_count":{"type":"integer","description":"Number of questions attached to this excerpt."}}}},"total":{"type":"integer","description":"Total number of excerpts in this chapter."},"limit":{"type":"integer","description":"Applied limit (after clamping)."},"offset":{"type":"integer","description":"Applied offset (after clamping)."}}},"example":{"items":[{"id":"e3f12a40-1111-4abc-9876-1234567890ab","user_id":"a1b2c3d4-5566-4778-8899-aabbccddeeff","list_id":"5c843ee2-11ba-4e2d-a300-99a9e1f31520","text":"It is a truth universally acknowledged, that a single man in possession of a good fortune, must be in want of a wife.","text_sentences":["It is a truth universally acknowledged, that a single man in possession of a good fortune, must be in want of a wife."],"audio_url":"","image_url":"","video_url":"","show_excerpt":true,"r2_audio":true,"sort_order":1000,"date_created":"2026-05-04T07:42:11.123Z","questions_count":3},{"id":"f4123b51-2222-4def-9876-1234567890ab","user_id":"a1b2c3d4-5566-4778-8899-aabbccddeeff","list_id":"5c843ee2-11ba-4e2d-a300-99a9e1f31520","text":"","text_sentences":[],"audio_url":"","image_url":"https://cdn.example.com/excerpts/pemberley.jpg","video_url":"","show_excerpt":true,"r2_audio":null,"sort_order":2000,"date_created":"2026-05-04T07:42:11.123Z","questions_count":2}],"total":12,"limit":20,"offset":0}}}},"400":{"description":"Missing or invalid chapter_id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"chapter_id must be a valid UUID"}}}},"401":{"description":"Unauthorized — Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"404":{"description":"Chapter not found or user lacks access (does not own the chapter and has not linked it or its parent book)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Chapter not found or not accessible"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch excerpts"}}}}}}},"/api/test/book/stats":{"get":{"summary":"Get Book Stats","description":"Returns BookStats for the per-book progress UI: book metadata, a roll-up across the whole book, and an ordered list of ChapterStats. The frontend handles chapter unlock gating.","operationId":"getTestBookStats","tags":["Tests"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"book_id","in":"query","required":true,"schema":{"type":"string","format":"uuid"},"description":"UUID of the book list to summarise."}],"responses":{"200":{"description":"Book metadata, rollup, and per-chapter stats","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BookStats"},"example":{"book_id":"5c843ee2-11ba-4e2d-a300-99a9e1f31520","book_name":"Pride and Prejudice","book_description":"A novel by Jane Austen first published in 1813.","book_image_url":"https://audio.lexflexer.com/books/pride-and-prejudice.jpg","total_chapters":3,"total_questions":47,"mastered_questions":12,"mastery_percent":25.5319,"last_tested":"2026-05-04T07:42:11.123Z","chapters":[{"chapter_id":"a1bb4644-27ac-49a6-b670-010f8b6d722a","chapter_name":"Chapter 1","sort_order":1000,"total_questions":18,"tested_count":18,"mastered_count":17,"in_progress_count":1,"mastery_percent":94.4444,"average_score":0.96,"last_tested":"2026-05-04T07:42:11.123Z"},{"chapter_id":"f242c1ec-8167-4d88-a49b-e03fed5b834a","chapter_name":"Chapter 2","sort_order":2000,"total_questions":15,"tested_count":5,"mastered_count":0,"in_progress_count":5,"mastery_percent":0,"average_score":0.4,"last_tested":"2026-05-03T18:11:00.000Z"},{"chapter_id":"079f5dfd-63ef-4947-a5d5-58fc84f08df7","chapter_name":"Chapter 3","sort_order":3000,"total_questions":14,"tested_count":0,"mastered_count":0,"in_progress_count":0,"mastery_percent":0,"average_score":0,"last_tested":null}]}}}},"400":{"description":"Missing or invalid book_id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"book_id must be a valid UUID"}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"404":{"description":"Book not found or not accessible","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Book not found or not accessible"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch book stats"}}}}}}},"/api/test/book/session-questions":{"get":{"summary":"Get Book Session Questions","description":"Per-question breakdown of a chapter test session, grouped by current cumulative answer_score (descending). Returns the question/answer/excerpt context plus the latest attempt's correctness and time_spent. Mirrors /api/test/vocab/session-words for chapter testing. Filters strictly to test_type=4 so a shared test_id between vocab and book sessions cannot bleed vocab attempts into the chapter view. Authorization is implicit via test_details.userid match — users can only see their own attempts.","operationId":"getBookSessionQuestions","tags":["Tests"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"$ref":"#/components/parameters/testIdQuery"}],"responses":{"200":{"description":"Array of score-grouped question buckets, sorted by answer_score descending","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","required":["answer_score","questions"],"properties":{"answer_score":{"type":"number","description":"Current cumulative mastery score (0-1) shared by every question in this bucket"},"questions":{"type":"array","items":{"type":"object","required":["id","question","answer","excerpt_id","answer_score","is_correct"],"properties":{"id":{"type":"string","format":"uuid","description":"questions.id (=test_details.dataid for test_type=4)"},"question":{"type":"string"},"answer":{"type":"string"},"excerpt_id":{"type":"string","format":"uuid","description":"Reference to the parent excerpt. Fetch full excerpt data via GET /api/test/book/excerpts."},"answer_score":{"type":"number","description":"Cumulative mastery score on the tests row (0-1)"},"is_correct":{"type":"boolean","description":"Whether the latest attempt in this session was correct"},"time_spent":{"type":"integer","nullable":true,"description":"Latest attempt time_spent in ms"}}}}}}},"example":[{"answer_score":1,"questions":[{"id":"550e8400-e29b-41d4-a716-446655440000","question":"What is the capital of France?","answer":"Paris","excerpt_id":"660e8400-e29b-41d4-a716-446655440001","answer_score":1,"is_correct":true,"time_spent":4200}]},{"answer_score":0.4,"questions":[{"id":"770e8400-e29b-41d4-a716-446655440002","question":"Who wrote Hamlet?","answer":"Shakespeare","excerpt_id":"880e8400-e29b-41d4-a716-446655440003","answer_score":0.4,"is_correct":false,"time_spent":8100}]}]}}},"400":{"description":"Missing or invalid test_id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"test_id must be a valid UUID"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch session questions"}}}}}}},"/api/test/correct":{"post":{"summary":"Record Correct Answer","description":"Records a correct answer (score=1) and updates progress for the test record. Works for vocab words (test_type 1-3) and chapter questions (test_type 4).","operationId":"createTestCorrect","tags":["Tests"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestAnswerInput"},"example":{"data_id":"550e8400-e29b-41d4-a716-446655440000","test_type":1,"test_id":"6ba7b810-9dad-11d1-80b4-00c04fd430c8","time_spent":5000}}}},"responses":{"200":{"description":"Correct answer recorded","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/TestAnswerResponse"},{"type":"object","properties":{"success":{"type":"boolean","example":true}},"required":["success"]}]},"example":{"success":true,"message":"Correct answer recorded","test_data":{"answer_score":0.85,"times_tested":5,"win_streak":3,"lose_streak":0,"answer_history":[1,1,1,0,1],"old_score":0.8,"new_score":0.85}}}}},"400":{"description":"Missing required fields or invalid UUID","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Missing required fields: data_id, test_id, or time_spent"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"404":{"description":"Test record not found for this word/user/mode","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Test record not found"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to record correct answer"}}}}}}},"/api/test/incorrect":{"post":{"summary":"Record Incorrect Answer","description":"Records an incorrect answer (score=0) and updates progress for the test record. Works for vocab words (test_type 1-3) and chapter questions (test_type 4).","operationId":"createTestIncorrect","tags":["Tests"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestAnswerInput"},"example":{"data_id":"550e8400-e29b-41d4-a716-446655440000","test_type":1,"test_id":"6ba7b810-9dad-11d1-80b4-00c04fd430c8","time_spent":5000}}}},"responses":{"200":{"description":"Incorrect answer recorded","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/TestAnswerResponse"},{"type":"object","properties":{"success":{"type":"boolean","example":true}},"required":["success"]}]},"example":{"success":true,"message":"Incorrect answer recorded","test_data":{"answer_score":0.6,"times_tested":5,"win_streak":0,"lose_streak":1,"answer_history":[1,1,0,0,0],"old_score":0.7,"new_score":0.6}}}}},"400":{"description":"Missing required fields or invalid UUID","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Missing required fields: data_id, test_id, or time_spent"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"404":{"description":"Test record not found for this word/user/mode","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Test record not found"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to record incorrect answer"}}}}}}},"/api/test/close":{"post":{"summary":"Record Close Answer","description":"Records a close/partially correct answer (score=0.5) and updates progress for the test record. Works for vocab words (test_type 1-3) and chapter questions (test_type 4).","operationId":"createTestClose","tags":["Tests"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestAnswerInput"},"example":{"data_id":"550e8400-e29b-41d4-a716-446655440000","test_type":1,"test_id":"6ba7b810-9dad-11d1-80b4-00c04fd430c8","time_spent":5000}}}},"responses":{"200":{"description":"Close answer recorded","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/TestAnswerResponse"},{"type":"object","properties":{"success":{"type":"boolean","example":true}},"required":["success"]}]},"example":{"success":true,"message":"Close answer recorded","test_data":{"answer_score":0.75,"times_tested":5,"win_streak":0,"lose_streak":0,"answer_history":[1,1,0.5,0,1],"old_score":0.7,"new_score":0.75}}}}},"400":{"description":"Missing required fields or invalid UUID","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Missing required fields: data_id, test_id, or time_spent"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"404":{"description":"Test record not found for this word/user/mode","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Test record not found"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to record close answer"}}}}}}},"/api/vocabulary/block_status":{"post":{"summary":"Update Block Status","description":"Block or unblock a word for the current user. Blocked words are excluded from all test modes and will not be added to the test pool. Uses the blocked_items table with blocked_type='vocab' (per-user, per-word — not per-mode).","operationId":"createVocabularyBlockStatus","tags":["Vocabulary"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["word_id","status"],"properties":{"word_id":{"type":"string","format":"uuid","description":"ID of the word to block/unblock"},"status":{"type":"boolean","description":"true to block, false to unblock"}}},"example":{"word_id":"550e8400-e29b-41d4-a716-446655440000","status":true}}}},"responses":{"200":{"description":"Block status updated","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"message":{"type":"string"},"word_id":{"type":"string","format":"uuid"},"blocked":{"type":"boolean"}}},"example":{"success":true,"message":"Block status updated","word_id":"550e8400-e29b-41d4-a716-446655440000","blocked":true}}}},"400":{"description":"Missing or invalid parameters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"word_id is required"}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"404":{"description":"Test record not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Test record not found"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to update block status"}}}}}}},"/api/vocabulary/report-error":{"post":{"summary":"Report Vocabulary Error","description":"Report an error on a vocabulary word. Only sets the error if no error has been reported yet for this word. Authenticated users only (no admin required).","operationId":"createVocabularyReportError","tags":["Vocabulary"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["word_id","message"],"properties":{"word_id":{"type":"string","format":"uuid","description":"ID of the word to report an error on"},"message":{"type":"string","minLength":1,"maxLength":500,"description":"Description of the error (1-500 characters)"}}},"example":{"word_id":"550e8400-e29b-41d4-a716-446655440000","message":"Definition is incorrect - this word means something else"}}}},"responses":{"200":{"description":"Error reported successfully or already reported","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"message":{"type":"string"}}},"example":{"success":true,"message":"Error reported successfully"}}}},"400":{"description":"Invalid request body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized - Authentication required"},"404":{"description":"Word not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Word not found"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to report error"}}}}}}},"/api/test/reset-progress":{"post":{"summary":"Reset Test Progress","description":"Resets test progress for a specific item. If test_type is provided, resets only that test type. If test_type is omitted, resets all three vocab test types (1=spelling, 2=definitionword, 3=worddefinition). Pass test_type=4 to reset a chapter question. Zeroes out answer_history, answer_score, times_tested, win_streak, and lose_streak. Non-existent data_id is a no-op (rows_affected=0, already_clean=true).","operationId":"createTestResetProgress","tags":["Tests"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["data_id"],"properties":{"data_id":{"type":"string","format":"uuid","description":"ID of the item to reset progress for (vocabulary word ID for test_type 1-3, question ID for test_type 4)"},"test_type":{"type":"integer","minimum":1,"maximum":4,"description":"Test type to reset (1=spelling, 2=definitionword, 3=worddefinition, 4=chapter question). If omitted, vocab types 1-3 are reset together."}}},"example":{"data_id":"550e8400-e29b-41d4-a716-446655440000","test_type":1}}}},"responses":{"200":{"description":"Progress reset successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"message":{"type":"string"},"data_id":{"type":"string","format":"uuid"},"reset_test_types":{"type":"array","items":{"type":"integer"},"description":"Test type integers that were reset (1=spelling, 2=definitionword, 3=worddefinition, 4=chapter question)"},"rows_affected":{"type":"integer","description":"Number of test records that were reset"},"already_clean":{"type":"boolean","description":"True if no test records were affected (item had no progress, or data_id does not exist)"}}},"example":{"success":true,"message":"Progress reset successfully","data_id":"550e8400-e29b-41d4-a716-446655440000","reset_test_types":[1],"rows_affected":1,"already_clean":false}}}},"400":{"description":"Missing/invalid data_id or test_type","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"data_id Must be a valid UUID"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to reset progress"}}}}}}},"/api/test/mark-understood":{"post":{"summary":"Mark Item as Understood","description":"Marks an item (vocabulary word or chapter question) as understood — drives the score to (historyLength-1)/historyLength so one more correct answer locks in mastery. Does not require time_spent.","operationId":"createTestMarkUnderstood","tags":["Tests"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["data_id","test_type","test_id"],"properties":{"data_id":{"type":"string","format":"uuid","description":"ID of the item to mark as understood (vocabulary word ID for test_type 1-3, question ID for test_type 4)"},"test_type":{"type":"integer","minimum":1,"maximum":4,"description":"1=spelling, 2=definitionword, 3=worddefinition, 4=chapter question."},"test_id":{"type":"string","format":"uuid","description":"Current test session ID"}}},"example":{"data_id":"550e8400-e29b-41d4-a716-446655440000","test_type":1,"test_id":"6ba7b810-9dad-11d1-80b4-00c04fd430c8"}}}},"responses":{"200":{"description":"Word marked as understood","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/TestAnswerResponse"},{"type":"object","properties":{"success":{"type":"boolean","example":true}},"required":["success"]}]},"example":{"success":true,"message":"Word marked as understood","test_data":{"answer_score":1,"times_tested":6,"win_streak":4,"lose_streak":0,"answer_history":[1,1,1,0,1],"old_score":0.85,"new_score":1}}}}},"400":{"description":"Missing required fields","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"word_id is required"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to mark word as understood"}}}}}}},"/api/test/vocab/top-words/spelling":{"get":{"summary":"Get Top Spelling Words","description":"Returns words grouped by answer score percentage for spelling mode. Each entry is a formatted string showing the score, count, and up to 20 example words. Words with 0% score are excluded.","operationId":"getTestTopWordsSpelling","tags":["Tests"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"responses":{"200":{"description":"Array of formatted score summary strings","content":{"application/json":{"schema":{"type":"array","items":{"type":"string"}},"example":["100%, total: 5, examples: [hello, world, test, foo, bar]","50%, total: 3, examples: [cat, dog, bird]"]}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch top words"}}}}}}},"/api/test/vocab/top-words/definitionword":{"get":{"summary":"Get Top Definitionword Words","description":"Returns words grouped by answer score percentage for definitionword mode (given a definition, pick the word). Each entry is a formatted string showing the score, count, and up to 20 example words. Words with 0% score are excluded.","operationId":"getTestTopWordsDefinitionword","tags":["Tests"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"responses":{"200":{"description":"Array of formatted score summary strings","content":{"application/json":{"schema":{"type":"array","items":{"type":"string"}},"example":["100%, total: 5, examples: [hello, world, test, foo, bar]","50%, total: 3, examples: [cat, dog, bird]"]}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch top words"}}}}}}},"/api/test/vocab/top-words/worddefinition":{"get":{"summary":"Get Top Worddefinition Words","description":"Returns words grouped by answer score percentage for worddefinition mode (given a word, pick the definition). Each entry is a formatted string showing the score, count, and up to 20 example words. Words with 0% score are excluded.","operationId":"getTestTopWordsWorddefinition","tags":["Tests"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"responses":{"200":{"description":"Array of formatted score summary strings","content":{"application/json":{"schema":{"type":"array","items":{"type":"string"}},"example":["100%, total: 5, examples: [hello, world, test, foo, bar]","50%, total: 3, examples: [cat, dog, bird]"]}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch top words"}}}}}}},"/api/test/session-results":{"get":{"summary":"Get Session Results","description":"Retrieve aggregate results for a specific test session — content-agnostic. Aggregates directly from `test_details` so it works for any test_type the session contains (vocab modes 1-3, chapter questions 4). Returns total distinct items tested, correct/incorrect counts (latest attempt per item wins on duplicates), accuracy %, and average time spent. testId can be provided as a query parameter or in the request body. Note: `total_words` field name is preserved for backwards compatibility — it counts distinct dataids regardless of whether they're vocabulary words or chapter questions.","operationId":"getTestSessionResults","tags":["Tests"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"$ref":"#/components/parameters/testIdQuery"}],"responses":{"200":{"description":"Session results with aggregate stats","content":{"application/json":{"schema":{"type":"object","required":["total_words","correct_count","incorrect_count","accuracy","average_time_spent"],"properties":{"total_words":{"type":"integer","description":"Total distinct items tested in session (vocab words for test_type 1-3, chapter questions for test_type 4). Field name kept for backwards compatibility."},"correct_count":{"type":"integer","description":"Number of correct answers"},"incorrect_count":{"type":"integer","description":"Number of incorrect answers"},"accuracy":{"type":"number","description":"Accuracy percentage (0-100)"},"average_time_spent":{"type":"number","description":"Average time spent per word"}}},"example":{"total_words":10,"correct_count":8,"incorrect_count":2,"accuracy":80,"average_time_spent":4500.5}}}},"400":{"description":"Missing testId","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"test_id is required"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Unauthorized"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch test session results"}}}}}}},"/api/test/vocab/session-words":{"get":{"summary":"Get Session Words","description":"Retrieve all words tested in a specific session with their results. testId can be provided as a query parameter or in the request body.","operationId":"getTestSessionWords","tags":["Tests"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"$ref":"#/components/parameters/testIdQuery"}],"responses":{"200":{"description":"Array of score-grouped word buckets, sorted by answer_score descending. Each bucket contains words that share the same cumulative score.","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","required":["answer_score","words"],"properties":{"answer_score":{"type":"number","description":"Current cumulative mastery score (0-1) shared by every word in this bucket"},"words":{"type":"array","items":{"type":"object","required":["id","word","definition","difficulty","answer_score","iscorrect"],"properties":{"id":{"type":"string","format":"uuid","description":"Word ID"},"word":{"type":"string","description":"The vocabulary word"},"definition":{"type":"string","description":"Word definition"},"definition_count":{"type":"integer","description":"Number of definitions for this word"},"difficulty":{"type":"integer","description":"Difficulty rank"},"answer_score":{"type":"number","description":"Current answer score for this word"},"iscorrect":{"type":"boolean","description":"Whether the last attempt was correct"},"timespent":{"type":"integer","description":"Time spent on this word (ms), may be null"}}}}}}},"example":[{"answer_score":0.85,"words":[{"id":"550e8400-e29b-41d4-a716-446655440000","word":"ephemeral","definition":"lasting a short time","difficulty":72,"answer_score":0.85,"iscorrect":true,"timespent":4500}]},{"answer_score":0.4,"words":[{"id":"660e8400-e29b-41d4-a716-446655440001","word":"aberrant","definition":"departing from an accepted standard","difficulty":85,"answer_score":0.4,"iscorrect":false,"timespent":6200}]}]}}},"400":{"description":"Missing testId","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"test_id is required"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Unauthorized"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch session words"}}}}}}},"/api/test/records/{dataId}/{testType}":{"get":{"summary":"Get Test Record","description":"Retrieve the full test record from the tests table for a specific user, item, and test type. Works for vocab words (test_type 1-3) and chapter questions (test_type 4).","operationId":"getTestRecord","tags":["Tests"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"dataId","in":"path","required":true,"description":"Item ID (UUID) — vocabulary word ID for test_type 1-3, question ID for test_type 4","schema":{"type":"string","format":"uuid"}},{"name":"testType","in":"path","required":true,"description":"1=spelling, 2=definitionword, 3=worddefinition, 4=chapter question","schema":{"type":"integer","enum":[1,2,3,4]}}],"responses":{"200":{"description":"Full test record (raw column names from database)","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"userid":{"type":"string"},"dataid":{"type":"string","description":"Polymorphic item ID — vocabulary.id for test_type 1-3, questions.id for test_type 4"},"test_type":{"type":"integer"},"answer_score":{"type":"number"},"times_tested":{"type":"integer"},"win_streak":{"type":"integer"},"lose_streak":{"type":"integer"},"lasttested":{"type":"string","format":"date-time"},"answer_history":{"type":"array","items":{"type":"number"}}}},"example":{"id":"990e8400-e29b-41d4-a716-446655440000","userid":"660e8400-e29b-41d4-a716-446655440001","dataid":"550e8400-e29b-41d4-a716-446655440000","test_type":1,"answer_score":0.85,"times_tested":5,"win_streak":3,"lose_streak":0,"lasttested":"2024-01-16T14:00:00Z","answer_history":[1,1,1,0,1]}}}},"400":{"description":"Missing required parameters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Missing required parameters: dataId and testType"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Unauthorized"}}}},"404":{"description":"Test record not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Test record not found"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to retrieve test record"}}}}}}},"/api/profile/data":{"get":{"summary":"Get Complete Profile Data","description":"Returns complete profile data including overview stats and rank breakdown in a single call.","tags":["Profile"],"parameters":[{"name":"mode","in":"query","required":false,"description":"Test mode to calculate stats for. Defaults to 'spelling'.","schema":{"type":"string","enum":["spelling","definitionword","worddefinition"],"default":"spelling"}}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","required":["overview","rank_breakdown"],"properties":{"overview":{"type":"object","properties":{"total_tests_taken":{"type":"integer"},"total_words_tested":{"type":"integer"},"words_learned":{"type":"integer"},"average_score":{"type":"integer"}}},"rank_breakdown":{"type":"array","items":{"type":"object","properties":{"level":{"type":"integer"},"name":{"type":"string"},"min_rank":{"type":"integer"},"max_rank":{"type":"integer"},"average_score":{"type":"number"},"total_words":{"type":"integer"},"tested_words":{"type":"integer"}}}}}},"example":{"overview":{"total_tests_taken":150,"total_words_tested":80,"words_learned":45,"average_score":72},"rank_breakdown":[{"level":1,"name":"Beginner","min_rank":1,"max_rank":20,"average_score":0.85,"total_words":500,"tested_words":30},{"level":2,"name":"Intermediate","min_rank":21,"max_rank":40,"average_score":0.65,"total_words":480,"tested_words":20},{"level":3,"name":"Advanced","min_rank":41,"max_rank":60,"average_score":0.45,"total_words":450,"tested_words":15},{"level":4,"name":"Expert","min_rank":61,"max_rank":80,"average_score":0.3,"total_words":400,"tested_words":10},{"level":5,"name":"Master","min_rank":81,"max_rank":100,"average_score":0.15,"total_words":350,"tested_words":5}]}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch profile data"}}}}},"operationId":"getProfileData"}},"/api/profile/tests-taken":{"get":{"summary":"Get Total Tests Taken","description":"Returns the total number of tests taken for a given mode, with an optional weekly breakdown.","tags":["Profile"],"parameters":[{"name":"mode","in":"query","required":false,"description":"Test mode. Defaults to 'spelling'.","schema":{"type":"string","enum":["spelling","definitionword","worddefinition"],"default":"spelling"}},{"name":"weeks","in":"query","required":false,"description":"Trim the returned weekly series to the most recent N weeks. Clamped to [1, 104]. Omit (or pass 0) to return full history.","schema":{"type":"integer","minimum":1,"maximum":104}}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","required":["total_tests_taken"],"properties":{"total_tests_taken":{"type":"integer","description":"Total number of tests taken"}}},"example":{"total_tests_taken":150}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch tests taken"}}}}},"operationId":"getProfileTestsTaken"}},"/api/profile/words-tested":{"get":{"summary":"Get Total Words Tested","description":"Returns the total number of unique words tested across all modes.","tags":["Profile"],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","required":["total_words_tested"],"properties":{"total_words_tested":{"type":"integer","description":"Total unique words tested"}}},"example":{"total_words_tested":500}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch words tested"}}}}},"operationId":"getProfileWordsTested"}},"/api/profile/words-learned":{"get":{"summary":"Get Total Words Learned","description":"Returns the count of words with mastery score above the learned threshold for a given mode.","tags":["Profile"],"parameters":[{"name":"mode","in":"query","required":false,"description":"Test mode. Defaults to 'spelling'.","schema":{"type":"string","enum":["spelling","definitionword","worddefinition"],"default":"spelling"}}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","required":["words_learned"],"properties":{"words_learned":{"type":"integer","description":"Words with mastery score above threshold"}}},"example":{"words_learned":200}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch words learned"}}}}},"operationId":"getProfileWordsLearned"}},"/api/profile/average-score":{"get":{"summary":"Get Average Score","description":"Returns the average test score as an integer (0-100) for a given mode.","tags":["Profile"],"parameters":[{"name":"mode","in":"query","required":false,"description":"Test mode. Defaults to 'spelling'.","schema":{"type":"string","enum":["spelling","definitionword","worddefinition"],"default":"spelling"}}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","required":["average_score"],"properties":{"average_score":{"type":"integer","description":"Average test score (0-100)"}}},"example":{"average_score":72}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch average score"}}}}},"operationId":"getProfileAverageScore"}},"/api/profile/calculate-rank":{"get":{"summary":"Calculate Rank Score","description":"Returns the average score for words within a specified difficulty rank range.","tags":["Profile"],"parameters":[{"$ref":"#/components/parameters/min_rank"},{"$ref":"#/components/parameters/max_rank"},{"name":"mode","in":"query","required":false,"description":"Test mode. Defaults to 'spelling'.","schema":{"type":"string","enum":["spelling","definitionword","worddefinition"],"default":"spelling"}}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","required":["min_rank","max_rank","average_score"],"properties":{"min_rank":{"type":"integer","description":"Lower bound of the rank range queried"},"max_rank":{"type":"integer","description":"Upper bound of the rank range queried"},"average_score":{"type":"number","description":"Average score for words in this rank range (0-1)"}}},"example":{"min_rank":1,"max_rank":100,"average_score":0.725}}}},"400":{"description":"Validation error — invalid request body/params/query"},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to calculate rank score"}}}}},"operationId":"getProfileCalculateRank"}},"/api/profile/me/check-username":{"get":{"summary":"Check Username Availability","description":"Checks whether a username is available for the current user. Returns validation errors for format issues (too short, invalid characters) and availability status for valid usernames. The check is case-insensitive and excludes the current user's own username.","tags":["Profile"],"parameters":[{"name":"username","in":"query","required":true,"description":"The username to check. Must be 5-20 characters, letters and numbers only.","schema":{"type":"string"}}],"responses":{"200":{"description":"Availability check result","content":{"application/json":{"schema":{"type":"object","required":["available","reason"],"properties":{"available":{"type":"boolean","description":"Whether the username is available"},"reason":{"type":"string","nullable":true,"description":"Reason the username is unavailable, or null if available"}}},"examples":{"available":{"summary":"Username is available","value":{"available":true,"reason":null}},"taken":{"summary":"Username is taken","value":{"available":false,"reason":"Username is already taken"}},"invalid":{"summary":"Username has invalid characters","value":{"available":false,"reason":"Username can only contain letters, numbers, and underscores"}}}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to check username availability"}}}}},"operationId":"checkUsernameAvailability"}},"/api/profile/word-mastery":{"get":{"summary":"Get Word Mastery Breakdown (Bucketed)","description":"Returns word mastery bucketed by the user's history_length setting. With history_length=4, you get 5 buckets: 0-24%, 25-49%, 50-74%, 75-99%, and 100% (mastered). Each bucket contains a list of word names. Optionally filter by a single test mode; when omitted, scores are averaged across all 3 modes. Use filter=current (default) to restrict to the user's active pool settings (userlist, rank range, category), or filter=all to include every tested word.","tags":["Profile"],"parameters":[{"name":"mode","in":"query","required":false,"description":"Filter to a single test mode. When omitted, averages scores across all 3 modes.","schema":{"type":"string","enum":["spelling","definitionword","worddefinition"]}},{"name":"filter","in":"query","required":false,"description":"Pool filter. 'current' (default) restricts to the user's active test settings (userlist, rank range, category). 'all' includes every word the user has ever been tested on.","schema":{"type":"string","enum":["all","current"],"default":"current"}}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","required":["history_length","mode","filter","buckets","total_words"],"properties":{"history_length":{"type":"integer","description":"The user's history_length setting (3-8), determines the number of buckets (history_length + 1)"},"mode":{"type":"string","nullable":true,"description":"The mode filter applied, or null if averaging all modes","enum":["spelling","definitionword","worddefinition",null]},"filter":{"type":"string","description":"Pool filter applied: 'current' or 'all'","enum":["all","current"]},"total_words":{"type":"integer","description":"Total unique words the user has been tested on"},"buckets":{"type":"array","description":"Mastery buckets from lowest to highest, with 100% (mastered) as the final bucket","items":{"type":"object","required":["label","min","max","words","count"],"properties":{"label":{"type":"string","description":"Human-readable bucket label (e.g. '0-24%', '100%')"},"min":{"type":"number","description":"Minimum score (inclusive) for this bucket"},"max":{"type":"number","description":"Maximum score (inclusive) for this bucket"},"words":{"type":"array","items":{"type":"string"},"description":"Word names in this bucket"},"count":{"type":"integer","description":"Number of words in this bucket"}}}}}},"example":{"history_length":4,"mode":null,"filter":"current","total_words":10,"buckets":[{"label":"0-24%","min":0,"max":0.249,"words":["dog","cat","mouse"],"count":3},{"label":"25-49%","min":0.25,"max":0.499,"words":["elephant","pigeon"],"count":2},{"label":"50-74%","min":0.5,"max":0.749,"words":["house","developer"],"count":2},{"label":"75-99%","min":0.75,"max":0.999,"words":["red","blue"],"count":2},{"label":"100%","min":1,"max":1,"words":["agnostic"],"count":1}]}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"404":{"description":"Resource not found"},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch word mastery"}}}}},"operationId":"getProfileWordMastery"}},"/api/profile/word-pool-stats":{"get":{"summary":"Get Word Pool Stats","description":"Returns counts for the user's active test word pool: words in the current pool (userlist or rank range), total words tested, and mastered words (answer_score = 1). Excludes blocked words and invalid vocabulary.","tags":["Profile"],"parameters":[{"name":"mode","in":"query","required":false,"description":"Test mode. Defaults to 'spelling'.","schema":{"type":"string","enum":["spelling","definitionword","worddefinition"],"default":"spelling"}}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","required":["pool_words","total_tested","mastered"],"properties":{"pool_words":{"type":"integer","description":"Words in the tests table that match the user's current pool (userlist or rank range)"},"total_tested":{"type":"integer","description":"Total words in the tests table for this user and mode"},"mastered":{"type":"integer","description":"Words with answer_score = 1 (fully mastered)"}}},"example":{"pool_words":45,"total_tested":120,"mastered":32}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"404":{"description":"User not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"User not found"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch word pool stats"}}}}},"operationId":"getProfileWordPoolStats"}},"/api/profile/level-word-counts":{"get":{"summary":"Get Level Word Counts","description":"Returns total words and tested words within a specified difficulty rank range.","tags":["Profile"],"parameters":[{"$ref":"#/components/parameters/min_rank"},{"$ref":"#/components/parameters/max_rank"},{"name":"mode","in":"query","required":false,"description":"Test mode. Defaults to 'spelling'.","schema":{"type":"string","enum":["spelling","definitionword","worddefinition"],"default":"spelling"}}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","required":["total_words","tested_words"],"properties":{"total_words":{"type":"integer","description":"Total vocabulary words in this rank range"},"tested_words":{"type":"integer","description":"Words the user has been tested on in this range"}}},"example":{"total_words":500,"tested_words":120}}}},"400":{"description":"Validation error — invalid request body/params/query"},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch level word counts"}}}}},"operationId":"getProfileLevelWordCounts"}},"/api/profile/blocked-words":{"get":{"summary":"Get Blocked Words","description":"Returns the user's blocked words list. Blocked words are excluded from all test modes.","operationId":"getProfileBlockedWords","tags":["Profile"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[],"responses":{"200":{"description":"List of blocked words","content":{"application/json":{"schema":{"type":"object","properties":{"words":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"word":{"type":"string"},"definition":{"type":"string","nullable":true}}}},"count":{"type":"integer"}}},"example":{"words":[{"id":"123e4567-e89b-12d3-a456-426614174000","word":"ubiquitous","definition":"present, appearing, or found everywhere"}],"count":1}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch blocked words"}}}}}}},"/api/speech/process-openai-batch":{"post":{"summary":"Process OpenAI TTS Batch","description":"Process TTS batch using OpenAI with warmup prefix. Raw audio is normalized to 48kHz stereo WAV before trimming and storage. Admin only.","operationId":"createSpeechProcessOpenaiBatch","tags":["Admin"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"limit":{"type":"integer","default":10,"description":"Max items to process"},"concurrency":{"type":"integer","default":3,"description":"Number of concurrent operations"},"manual_trim_start":{"type":"number","description":"Seconds to trim from audio start"},"warmup_word":{"type":"string","default":"warmup","description":"Word spoken before target word"},"delimiter":{"type":"string","default":"|||","description":"Separates warmup from target word"},"delimiter_replacement":{"type":"string","default":"\n","description":"Replacement for delimiter in TTS input"},"voice":{"type":"string","default":"alloy","description":"OpenAI TTS voice","enum":["alloy","echo","fable","onyx","nova","shimmer","ash","ballad","sage","verse","marin","cedar"]},"wordsearch":{"type":"string","description":"Filter words containing this string"},"word_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":100,"description":"Specific vocabulary word IDs to process. When provided, skips random selection and processes these words directly (only checks audio_exists=false)."},"min_rank":{"type":"integer","minimum":1,"maximum":100,"description":"Minimum difficulty rank (1-100) to filter eligible words"},"max_rank":{"type":"integer","minimum":1,"maximum":100,"description":"Maximum difficulty rank (1-100) to filter eligible words"}}},"example":{"limit":10,"concurrency":3,"warmup_word":"warmup","delimiter":"|||","voice":"alloy"}}}},"responses":{"200":{"description":"Batch processing results","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","description":"Whether any words were processed successfully"},"processedCount":{"type":"integer","description":"Number of words successfully processed"},"totalAttempted":{"type":"integer","description":"Total number of words attempted"},"failedWords":{"type":"array","items":{"type":"string"},"description":"Words that failed processing"},"errors":{"type":"array","items":{"type":"string"},"description":"Error messages"},"audioStats":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"word":{"type":"string"},"length":{"type":"integer","description":"Audio buffer size in bytes"},"duration":{"type":"number","description":"Audio duration in seconds"},"inputUsed":{"type":"string","description":"TTS input string used"}}},"description":"Per-word audio generation stats"}}},"example":{"success":true,"processedCount":9,"totalAttempted":10,"failedWords":["xyz"],"errors":["xyz: TTS generation failed"],"audioStats":[{"id":"uuid","word":"hello","length":96000,"duration":1,"inputUsed":"warmup|||hello"}]}}}},"400":{"description":"Invalid request body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Invalid request parameters"}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Forbidden - Admin access required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Admin access required"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to process OpenAI TTS batch"}}}}},"x-requires-admin":true}},"/api/examples":{"get":{"summary":"Get All Examples","description":"Retrieve all examples/quotes with pagination. Admin only.","tags":["Admin"],"parameters":[{"name":"limit","in":"query","description":"Maximum number of examples to return (default 100, max 1000)","schema":{"type":"integer","default":100,"minimum":1,"maximum":1000}},{"name":"offset","in":"query","description":"Number of examples to skip","schema":{"type":"integer","default":0,"minimum":0}}],"responses":{"200":{"description":"List of examples with pagination info","content":{"application/json":{"schema":{"type":"object","required":["examples","total","limit","offset"],"properties":{"examples":{"type":"array","items":{"type":"object","required":["id","quote","source","date_added"],"properties":{"id":{"type":"string"},"quote":{"type":"string"},"source":{"type":"string"},"source_type":{"type":["string","null"]},"date_added":{"type":"string","format":"date-time"}}}},"total":{"type":"integer"},"limit":{"type":"integer"},"offset":{"type":"integer"}}},"example":{"examples":[{"id":"550e8400-e29b-41d4-a716-446655440000","quote":"The ephemeral nature of life reminds us to cherish every moment.","source":"Philosophy Journal","source_type":"article","date_added":"2025-01-15T10:30:00.000Z"}],"total":42,"limit":100,"offset":0}}}},"401":{"description":"Unauthorized"},"403":{"description":"Admin access required"},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Internal Server Error"}}}}},"operationId":"getExamples","x-requires-admin":true},"post":{"summary":"Create Example(s)","description":"Create one or more examples/quotes. Supports single insert or bulk insert (up to 1000). Admin only.","tags":["Admin"],"requestBody":{"content":{"application/json":{"schema":{"oneOf":[{"type":"object","properties":{"quote":{"type":"string","description":"The quote or excerpt text","maxLength":2000},"source":{"type":"string","description":"Author, magazine, article, etc.","maxLength":200},"source_type":{"type":"string","description":"Category: book, article, news, etc."}},"required":["quote","source"]},{"type":"object","properties":{"examples":{"type":"array","description":"Array of examples for bulk insert (max 1000)","maxItems":1000,"items":{"type":"object","properties":{"quote":{"type":"string","maxLength":2000},"source":{"type":"string","maxLength":200},"source_type":{"type":"string"}},"required":["quote","source"]}}},"required":["examples"]}]},"example":{"quote":"The ephemeral nature of life reminds us to cherish every moment.","source":"Philosophy Journal"}}},"required":true},"responses":{"201":{"description":"Example(s) created successfully","content":{"application/json":{"schema":{"oneOf":[{"type":"object","description":"Single insert response — returns the created Example","properties":{"success":{"type":"boolean","example":true},"id":{"type":"string","format":"uuid"},"quote":{"type":"string"},"source":{"type":"string"},"source_type":{"type":["string","null"]},"date_added":{"type":"string","format":"date-time"}},"required":["success","id","quote","source","date_added"]},{"type":"object","description":"Bulk insert response — returns count and message","properties":{"success":{"type":"boolean","example":true},"inserted":{"type":"integer","description":"Number of examples created"},"message":{"type":"string"}},"required":["success","inserted","message"]}]},"examples":{"single":{"summary":"Single insert","value":{"success":true,"id":"550e8400-e29b-41d4-a716-446655440000","quote":"The ephemeral nature of life reminds us to cherish every moment.","source":"Philosophy Journal","source_type":"article","date_added":"2025-01-15T10:30:00.000Z"}},"bulk":{"summary":"Bulk insert","value":{"success":true,"inserted":5,"message":"5 examples created"}}}}}},"400":{"description":"Validation error — quote and source are required fields","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"missing_fields":{"summary":"Required fields missing","value":{"error":"quote: Required, source: Required"}},"too_long":{"summary":"Field exceeds max length","value":{"error":"Quote must be 2000 characters or less"}}}}}},"401":{"description":"Unauthorized"},"403":{"description":"Admin access required"},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Internal Server Error"}}}}},"operationId":"createExamples","x-requires-admin":true}},"/api/examples/{id}":{"delete":{"summary":"Delete Example","description":"Delete a specific example by ID. Admin only.","tags":["Admin"],"parameters":[{"$ref":"#/components/parameters/id"}],"responses":{"200":{"description":"Example deleted successfully","content":{"application/json":{"schema":{"type":"object","required":["success","message","id"],"properties":{"success":{"type":"boolean","example":true},"message":{"type":"string"},"id":{"type":"string","format":"uuid"}}},"example":{"success":true,"message":"Example deleted","id":"550e8400-e29b-41d4-a716-446655440000"}}}},"400":{"description":"Bad Request - Missing or invalid ID","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Invalid example ID format. Must be a UUID."}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Forbidden - Admin access required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Admin access required"}}}},"404":{"description":"Example not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Example not found"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Internal Server Error"}}}}},"operationId":"deleteExamplesByid","x-requires-admin":true}},"/api/test/vocab/getword/definition/definitionword":{"get":{"summary":"Get a definition, select the correct word","description":"Get a word for definition-to-word test — given the definition, user selects the correct word. Includes multiple choice options.","operationId":"getTestGetwordDefinitionDefinitionword","tags":["Tests"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"$ref":"#/components/parameters/testIdQuery"}],"responses":{"200":{"description":"Word with progress data and multiple choice options","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestWordResponse"},"example":{"word_id":"550e8400-e29b-41d4-a716-446655440000","test_id":"6ba7b810-9dad-11d1-80b4-00c04fd430c8","mode":"definitionword","word":{"id":"550e8400-e29b-41d4-a716-446655440000","word":"ephemeral","definition":"lasting a short time","difficulty":72,"answer_score":0.85,"times_tested":10,"win_streak":3,"lose_streak":0,"answer_history":[1,1,0,1]},"options":["lasting a short time","extremely large","difficult to understand","happening regularly"]}}}},"400":{"description":"Missing or invalid testId","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"test_id is required"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Forbidden — insufficient permissions or activity limit reached"},"404":{"description":"No words available or word not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"No words available"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch word"}}}}}}},"/api/test/vocab/getword/definition/worddefinition":{"get":{"summary":"Get a word, select the correct definition","description":"Get a word for word-to-definition test — given the word, user selects the correct definition. Includes multiple choice options.","operationId":"getTestGetwordDefinitionWorddefinition","tags":["Tests"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"$ref":"#/components/parameters/testIdQuery"}],"responses":{"200":{"description":"Word with progress data and multiple choice options","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestWordResponse"},"example":{"word_id":"550e8400-e29b-41d4-a716-446655440000","test_id":"6ba7b810-9dad-11d1-80b4-00c04fd430c8","mode":"worddefinition","word":{"id":"550e8400-e29b-41d4-a716-446655440000","word":"ephemeral","definition":"lasting a short time","difficulty":72,"answer_score":0.85,"times_tested":10,"win_streak":3,"lose_streak":0,"answer_history":[1,1,0,1]},"options":["ephemeral","eternal","ambiguous","prolific"]}}}},"400":{"description":"Missing or invalid testId","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"test_id is required"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Forbidden — insufficient permissions or activity limit reached"},"404":{"description":"No words available or word not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"No words available"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch word"}}}}}}},"/api/admin/vocabulary/bulk-delete":{"post":{"summary":"Bulk Delete Vocabulary","description":"Delete multiple vocabulary words and their related test records. Maximum 200 words per request. Admin only.","operationId":"createAdminVocabularyBulkDelete","tags":["Admin"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["ids"],"properties":{"ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":200,"description":"Array of vocabulary word IDs to delete. Maximum 200 items."}}},"example":{"ids":["550e8400-e29b-41d4-a716-446655440000","6ba7b810-9dad-11d1-80b4-00c04fd430c8"]}}}},"responses":{"200":{"description":"Bulk delete completed","content":{"application/json":{"schema":{"type":"object","required":["success","deleted_count","deleted_ids"],"properties":{"success":{"type":"boolean","example":true},"deleted_count":{"type":"integer","description":"Number of words deleted"},"deleted_ids":{"type":"array","items":{"type":"string","format":"uuid"},"description":"IDs of deleted words"}}},"example":{"success":true,"deleted_count":2,"deleted_ids":["550e8400-e29b-41d4-a716-446655440000","6ba7b810-9dad-11d1-80b4-00c04fd430c8"]}}}},"400":{"description":"Invalid request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"ids must be an array"}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Forbidden - Admin access required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Admin access required"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to bulk delete vocabulary"}}}}},"x-requires-admin":true}},"/api/admin/ai-helpers/difficulty-random":{"post":{"summary":"AI Difficulty Ranking (Batch of 10 or by ID)","description":"Ranks vocabulary words' difficulty using Claude AI. When word_id is provided, processes just that word. Otherwise picks up to 10 unprocessed words (difficulty = 101, word_interest.overall > 0) and ranks them in a single AI call with relative comparison.","operationId":"createAdminVocabularyAiDifficultyRandom","tags":["Admin"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AiBatchBody"},"example":{}}}},"responses":{"200":{"description":"Ranking results with progress info","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"processed":{"type":"integer","description":"Number of words successfully ranked"},"errors":{"type":"integer","description":"Number of words that failed to rank"},"remaining":{"type":"integer","description":"Number of unprocessed words still at difficulty 101"},"results":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"word":{"type":"string"},"difficulty":{"type":"integer","minimum":1,"maximum":101},"error":{"type":"string"}}}},"message":{"type":"string","description":"Present when no unprocessed words remain"}}},"examples":{"success":{"summary":"Batch processed","value":{"success":true,"processed":98,"errors":2,"remaining":1432,"results":[{"id":"550e8400-e29b-41d4-a716-446655440000","word":"ephemeral","difficulty":72},{"id":"6ba7b810-9dad-11d1-80b4-00c04fd430c8","word":"cat","difficulty":5}]}},"allDone":{"summary":"No unprocessed words","value":{"success":true,"message":"No unprocessed words remaining","results":[],"processed":0,"total":0}}}}}},"400":{"description":"Validation error — invalid request body/params/query"},"401":{"description":"Unauthorized - Authentication required"},"403":{"description":"Forbidden - Admin access required"},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to rank word difficulties"}}}}},"x-requires-admin":true}},"/api/admin/ai-helpers/interest-random":{"post":{"summary":"AI Interest Scoring (Random Batch or by ID)","description":"Score words across 10 interest criteria using Claude AI. When word_id is provided, processes just that word (re-scores even if already scored). Otherwise picks unscored words ordered by word ID. Returns individual scores (1-100) plus overall average and phonetic pronunciation. Stops on first error.","operationId":"createAdminVocabularyAiInterestRandom","tags":["Admin - Dictionary Enrichment"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AiBatchBody"},"example":{}}}},"responses":{"200":{"description":"Interest scoring results","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"error":{"type":"boolean"},"error_message":{"type":"string"},"error_word":{"type":"string"},"processed":{"type":"integer"},"remaining":{"type":"integer"},"results":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"word":{"type":"string"},"overall":{"type":"integer"},"scores":{"type":"object","properties":{"sound":{"type":"integer"},"depth":{"type":"integer"},"etymology":{"type":"integer"},"timelessness":{"type":"integer"},"imagery":{"type":"integer"},"uniqueness":{"type":"integer"},"story":{"type":"integer"},"emotion":{"type":"integer"},"versatility":{"type":"integer"},"precision":{"type":"integer"}}},"reasoning":{"type":"string"}}}}}},"example":{"success":true,"error":false,"processed":1,"remaining":4200,"results":[{"id":"123e4567-e89b-12d3-a456-426614174000","word":"ephemeral","overall":74,"scores":{"sound":72,"depth":85,"etymology":90,"timelessness":65,"imagery":78,"uniqueness":80,"story":70,"emotion":60,"versatility":55,"precision":88},"reasoning":"A rich word with strong etymological roots and evocative imagery."}]}}}},"400":{"description":"Validation error — invalid request body/params/query"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin access required"},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":true}}}}},"x-requires-admin":true}},"/api/admin/ai-helpers/phonetic-random":{"post":{"summary":"Backfill Phonetic Pronunciations (Batch of 20)","description":"Generates simple phonetic pronunciations (e.g. 'eh-FEM-er-ul') for interest-scored words that are missing one. Processes 20 words per call in a single AI request. Accepts the shared aiBatchBodySchema — pass nothing for random, word_id for a single word, or word_ids for up to 100.","operationId":"createAdminVocabularyPhoneticRandom","tags":["Admin - Dictionary Enrichment"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AiBatchBody"},"example":{}}},"required":false},"responses":{"200":{"description":"Phonetic generation results","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"error":{"type":"boolean"},"error_message":{"type":"string"},"processed":{"type":"integer","description":"Words successfully updated"},"remaining":{"type":"integer","description":"Interest-scored words still missing a phonetic"},"results":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"word":{"type":"string"},"phonetic":{"type":"string","nullable":true,"description":"Generated phonetic or null if AI omitted it"}}}}}},"example":{"success":true,"error":false,"processed":20,"remaining":29980,"results":[{"id":"550e8400-e29b-41d4-a716-446655440000","word":"ephemeral","phonetic":"eh-FEM-er-ul"},{"id":"660e8400-e29b-41d4-a716-446655440000","word":"serendipity","phonetic":"ser-en-DIP-ih-tee"}]}}}},"400":{"description":"Validation error — invalid request body/params/query"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin access required"},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":true,"error_message":"Failed to parse phonetic batch"}}}}},"x-requires-admin":true}},"/api/admin/ai-helpers/related-words-random":{"post":{"summary":"Generate Related Words (Batch of 10)","description":"Generates up to 5 synonyms, 5 antonyms, and 5 hyponyms for vocabulary words using AI. Processes up to 10 words per call in a single AI request. Accepts optional word_id for a specific word, word_ids for a bulk batch, or no body for random selection.","operationId":"createAdminVocabularyRelatedWordsRandom","tags":["Admin - Dictionary Enrichment"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AiBatchBody"},"example":{}}}},"responses":{"200":{"description":"Related words generation results","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"error":{"type":"boolean"},"error_message":{"type":"string"},"rate_limited":{"type":"boolean"},"processed":{"type":"integer","description":"Words successfully updated"},"remaining":{"type":"integer","description":"Words still missing related words"},"results":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"word":{"type":"string"},"synonyms":{"type":"array","items":{"type":"string"},"description":"Up to 5 synonyms"},"antonyms":{"type":"array","items":{"type":"string"},"description":"Up to 5 antonyms"},"hyponyms":{"type":"array","items":{"type":"string"},"description":"Up to 5 hyponyms (more specific types)"}}}}}},"example":{"success":true,"error":false,"processed":2,"remaining":4998,"results":[{"id":"550e8400-e29b-41d4-a716-446655440000","word":"ephemeral","synonyms":["fleeting","transient","momentary"],"antonyms":["permanent","enduring","lasting"],"hyponyms":[]},{"id":"660e8400-e29b-41d4-a716-446655440000","word":"vehicle","synonyms":["conveyance","transport"],"antonyms":[],"hyponyms":["car","truck","bicycle","motorcycle","bus"]}]}}}},"400":{"description":"Validation error — invalid request body/params/query"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin access required"},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":true,"error_message":"Failed to parse related words batch"}}}}},"x-requires-admin":true}},"/api/admin/vocabulary/{id}/audio":{"post":{"summary":"Upload Audio","description":"Upload a base64-encoded WAV audio pronunciation for a vocabulary word. Maximum 2MB. Admin only.","operationId":"createAdminVocabularyAudio","tags":["Admin"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"$ref":"#/components/parameters/id"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["audio_data","mime_type"],"properties":{"audio_data":{"type":"string","description":"Base64-encoded audio data"},"mime_type":{"type":"string","description":"Audio MIME type (only audio/wav accepted)","enum":["audio/wav"]}}},"example":{"audio_data":"UklGRi4AAABXQVZFZm10IBAAAAABAAE...","mime_type":"audio/wav"}}}},"responses":{"200":{"description":"Audio uploaded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageResponse"},"example":{"message":"Audio uploaded successfully"}}}},"400":{"description":"Missing fields or invalid MIME type","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"audio_data and mime_type are required"}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Forbidden - Admin access required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Admin access required"}}}},"404":{"description":"Vocabulary word not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Vocabulary word not found"}}}},"413":{"description":"Payload too large","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Audio file too large. Maximum size is 2MB."}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to upload audio"}}}}},"x-requires-admin":true},"delete":{"summary":"Delete Audio","description":"Remove audio pronunciation from a vocabulary word. Clears audio data and resets audio flags. Admin only.","operationId":"deleteAdminVocabularyAudio","tags":["Admin"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"$ref":"#/components/parameters/id"}],"responses":{"200":{"description":"Audio deleted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageResponse"},"example":{"message":"Audio deleted successfully"}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Forbidden - Admin access required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Admin access required"}}}},"404":{"description":"Vocabulary word not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Vocabulary word not found"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to delete audio"}}}}},"x-requires-admin":true}},"/api/admin/vocabulary/{id}/record-audio":{"post":{"summary":"Record Audio (TTS)","description":"Initiate text-to-speech audio recording for a vocabulary word. Returns the word object after processing. Admin only.","operationId":"createAdminVocabularyRecordAudio","tags":["Admin"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"$ref":"#/components/parameters/id"}],"responses":{"200":{"description":"Word object after audio recording initiated","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"id":{"type":"string","format":"uuid"},"word":{"type":"string"},"difficulty":{"type":"integer"},"audio_mime_type":{"type":["string","null"]},"audio_exists":{"type":"boolean"},"audio_confirmed":{"type":"boolean"},"report_error":{"type":"string"},"has_definition":{"type":"boolean"},"deprecated":{"type":"boolean"},"definition":{"type":["string","null"]},"definition_count":{"type":"integer","description":"Number of definitions for this word"},"last_updated":{"type":"string","format":"date-time"},"date":{"type":"string","format":"date-time"},"date_created":{"type":"string","format":"date-time","description":"Immutable creation timestamp"},"audio_data":{"type":["string","null"]},"example_quote_id":{"type":["string","null"],"format":"uuid"},"frequency":{"type":"number"},"audio_duration":{"type":"integer","nullable":true,"description":"Audio clip duration in milliseconds"},"r2_audio":{"type":"boolean","nullable":true,"description":"R2 audio status: true = on R2, false = failed, null = local"}}},"example":{"success":true,"id":"550e8400-e29b-41d4-a716-446655440000","word":"ephemeral","difficulty":75,"audio_mime_type":"audio/wav","audio_exists":true,"audio_confirmed":false,"report_error":"","has_definition":true,"deprecated":false,"definition":"Lasting for a very short time","last_updated":"2026-02-11T12:00:00.000Z","date":"2026-01-15T12:00:00.000Z","date_created":"2026-01-15T12:00:00.000Z","audio_data":null,"example_quote_id":null,"frequency":5.2}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Forbidden - Admin access required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Admin access required"}}}},"404":{"description":"Word not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Word not found"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to initiate audio recording"}}}}},"x-requires-admin":true}},"/api/admin/logs":{"get":{"summary":"Get API Logs","description":"Fetch recent API request logs. Admin only.","operationId":"getAdminLogs","tags":["Admin"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"limit","in":"query","required":false,"description":"Maximum number of log entries to return (default 100)","schema":{"type":"integer","default":100}}],"responses":{"200":{"description":"Array of API log entries","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"user_id":{"type":["string","null"],"format":"uuid"},"method":{"type":"string","description":"HTTP method (GET, POST, etc.)"},"path":{"type":"string"},"status_code":{"type":["integer","null"]},"duration":{"type":["integer","null"],"description":"Request duration in milliseconds"},"ip_address":{"type":["string","null"]},"user_agent":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time"}}}},"example":[{"id":"550e8400-e29b-41d4-a716-446655440000","user_id":"6ba7b810-9dad-11d1-80b4-00c04fd430c8","method":"GET","path":"/api/test/vocab/getword/spelling","status_code":200,"duration":45,"ip_address":"192.168.1.1","user_agent":"Mozilla/5.0","created_at":"2026-02-11T10:00:00.000Z"}]}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Forbidden - Admin access required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Admin access required"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch logs"}}}}},"x-requires-admin":true}},"/api/admin/debug-email":{"post":{"summary":"Send Debug Email","description":"Send a test email for debugging purposes. Admin only.","operationId":"createAdminDebugEmail","tags":["Admin"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["email","subject","body"],"properties":{"email":{"type":"string","format":"email","description":"Recipient email address"},"subject":{"type":"string","description":"Email subject line"},"body":{"type":"string","description":"Email body content"}}},"example":{"email":"test@example.com","subject":"Debug Test","body":"This is a test email."}}}},"responses":{"200":{"description":"Email sent","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageResponse"},"example":{"message":"Email sent successfully"}}}},"400":{"description":"Missing required fields","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"email, subject, and body are required"}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Forbidden - Admin access required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Admin access required"}}}},"404":{"description":"Resource not found"},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Internal Server Error"}}}}},"x-requires-admin":true}},"/api/admin/word-count":{"get":{"summary":"Get Word Count Statistics","description":"Get total and validated vocabulary word counts. Admin only.","operationId":"getAdminWordCount","tags":["Admin"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"responses":{"200":{"description":"Word count statistics","content":{"application/json":{"schema":{"type":"object","required":["total","validated"],"properties":{"total":{"type":"integer","description":"Total number of vocabulary words"},"validated":{"type":"integer","description":"Words with audio, confirmed audio, and definition"}}},"example":{"total":5000,"validated":3200}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Forbidden - Admin access required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Admin access required"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch word count"}}}}},"x-requires-admin":true}},"/api/admin/validate-word":{"post":{"summary":"Validate Word","description":"Check if a word, rank, and definition combination passes validation rules. Admin only.","operationId":"createAdminValidateWord","tags":["Admin"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["word","rank","definition"],"properties":{"word":{"type":"string","description":"The word to validate (1-50 characters)"},"rank":{"type":"integer","description":"Difficulty rank (1-100)","minimum":1,"maximum":100},"definition":{"type":"string","description":"Word definition (max 1000 characters)","maxLength":1000}}},"example":{"word":"ephemeral","rank":75,"definition":"Lasting for a very short time"}}}},"responses":{"200":{"description":"Validation result","content":{"application/json":{"schema":{"type":"object","required":["success","is_valid"],"properties":{"success":{"type":"boolean","example":true},"is_valid":{"type":"boolean","description":"Whether the word passes validation"}}},"example":{"success":true,"is_valid":true}}}},"400":{"description":"Missing required fields","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"word, rank, and definition are required"}}}},"401":{"description":"Unauthorized - Authentication required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"403":{"description":"Forbidden - Admin access required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Admin access required"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to validate word"}}}}},"x-requires-admin":true}},"/api/admin/definition":{"post":{"summary":"Add Manual Definition","description":"Admin-only endpoint to insert a manually-authored definition for a word. The definition text is scanned for new vocabulary words (via scanTextForWords) as a side effect.","operationId":"createManualDefinition","tags":["Admin - Definitions"],"security":[{"sessionAuth":[]},{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["word_id","definition"],"properties":{"word_id":{"type":"string","format":"uuid"},"definition":{"type":"string","minLength":1,"maxLength":2000}}},"example":{"word_id":"01c0755d-34ef-4a4c-af0a-4281b2e92147","definition":"Absence of normal vision in one or both eyes."}}}},"responses":{"201":{"description":"Definition created","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"id":{"type":"string","format":"uuid"},"word_id":{"type":"string","format":"uuid"},"definition":{"type":"string"},"length":{"type":"integer"},"date_added":{"type":"string","format":"date-time"},"source":{"type":"string","example":"manual"}}}}}},"400":{"description":"Validation error (missing/invalid word_id, definition out of bounds)"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden — admin access required"},"500":{"description":"Server error inserting definition"}},"x-requires-admin":true}},"/api/admin/definition/{id}":{"get":{"summary":"Get all definitions for a word","description":"Returns all definitions stored for a vocabulary word by its ID.","tags":["Admin - Definitions"],"security":[{"sessionAuth":[]},{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"For GET: vocabulary word's UUID. For DELETE: definition's UUID. Same URL pattern, different semantic — check the operation description."}],"responses":{"200":{"description":"Definitions retrieved successfully","content":{"application/json":{"schema":{"type":"object","properties":{"word_id":{"type":"string","format":"uuid"},"definitions":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"word_id":{"type":"string","format":"uuid"},"definition":{"type":"string"},"length":{"type":"integer"},"date_added":{"type":"string","format":"date-time"}}}},"count":{"type":"integer","description":"Number of definitions"}}}}}},"401":{"description":"Unauthorized — authentication required"},"403":{"description":"Forbidden — admin access required"},"500":{"description":"Server error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}}}}}},"x-requires-admin":true},"delete":{"summary":"Delete a definition","description":"Deletes a single definition by its ID. Syncs vocabulary backward-compat fields (definition_count, has_definition, definition). Admin only.","operationId":"deleteAdminDefinition","tags":["Admin - Definitions"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"word_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Definition ID to delete"}],"responses":{"200":{"description":"Definition deleted successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"deleted_id":{"type":"string","format":"uuid"},"word_id":{"type":"string","format":"uuid"}}}}}},"404":{"description":"Definition not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/admin/ai-helpers/definition-random":{"post":{"summary":"Generate AI Definitions (Random or by ID)","description":"Generates up to 3 distinct definitions per word in a single AI call. When word_id is provided, processes that specific word. Otherwise picks one random word with fewer than 3 definitions (word_interest.overall>0, not deprecated/flagged). Existing definitions are passed for deduplication. Returns INVALID for non-English words. Admin only.","operationId":"createAdminGenerateDefinitionRandom","tags":["Admin"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AiBatchBody"},"example":{}}}},"responses":{"200":{"description":"Batch processing results","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"error":{"type":"boolean"},"error_message":{"type":"string","description":"Present when error is true"},"error_word":{"type":"string","description":"Word that caused the error"},"processed":{"type":"integer","description":"Number of words successfully processed"},"remaining":{"type":"integer","description":"Words still with fewer than 3 definitions (counted after processing)"},"results":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"word":{"type":"string"},"definitions_added":{"type":"integer","description":"Number of definitions added (up to 3)"}}}}}},"example":{"success":true,"error":false,"processed":3,"remaining":120,"results":[{"id":"550e8400-e29b-41d4-a716-446655440000","word":"ecstatic","definitions_added":3},{"id":"660e8400-e29b-41d4-a716-446655440000","word":"zephyr","definitions_added":2}]}}}},"400":{"description":"Validation error — invalid request body/params/query"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin access required"},"404":{"description":"Resource not found"},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":true}}}}},"x-requires-admin":true}},"/api/admin/definition/parse":{"post":{"summary":"Parse Unparsed Definitions","description":"Scans unparsed definitions for vocabulary words and adds any missing words to the vocabulary table. Processes definitions where parsed=false, runs each through the word scanner, then marks them as parsed=true. Use to backfill existing definitions. Admin only.","operationId":"createAdminParseDefinitions","tags":["Admin - Definitions"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"limit","in":"query","required":false,"description":"Number of definitions to process (1–500, default 100)","schema":{"type":"integer","minimum":1,"maximum":500,"default":100}}],"responses":{"200":{"description":"Parse results","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"message":{"type":"string","description":"Present when no unparsed definitions remain"},"processed":{"type":"integer","description":"Number of definitions parsed"},"total_existing":{"type":"integer","description":"Total words already in vocabulary across all parsed definitions"},"total_added":{"type":"integer","description":"Total new words added to vocabulary"},"remaining":{"type":"integer","description":"Definitions still unparsed"}}},"example":{"success":true,"processed":100,"total_existing":412,"total_added":23,"remaining":1850}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin access required"},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to parse definitions"}}}}},"x-requires-admin":true}},"/api/admin/ai-helpers/enrich-random":{"post":{"summary":"Enrich Words (Random or by ID)","description":"Enriches words with Free Dictionary API data (pronunciation, synonyms, antonyms, word forms, category, form-of detection). When word_id is provided, processes that specific word. Otherwise picks 2 random unenriched words. Auto-sets flagged_invalid when word not found in dictionary. Detects and links word forms (plurals, past tenses, etc.) back to base words.","operationId":"enrichRandomWords","tags":["Admin - Dictionary Enrichment"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AiBatchBody"},"example":{}}}},"responses":{"200":{"description":"Batch enrichment results","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"processed":{"type":"integer"},"errors":{"type":"integer"},"remaining":{"type":"integer"},"results":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"word":{"type":"string"},"definitions_added":{"type":"integer"},"quotes_added":{"type":"integer"},"has_pronunciation":{"type":"boolean"},"has_etymology":{"type":"boolean"},"synonym_count":{"type":"integer"},"antonym_count":{"type":"integer"},"form_count":{"type":"integer"},"plural":{"type":"string","nullable":true},"category_updated":{"type":"boolean"},"error":{"type":"string"}}}}}}}}},"400":{"description":"Validation error — invalid request body/params/query"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin access required"},"404":{"description":"Resource not found"},"500":{"description":"Internal server error"}},"x-requires-admin":true}},"/api/admin/vocabulary/backfill-forms":{"post":{"summary":"Backfill Word Forms from Free Dictionary","description":"Scans enriched words that aren't yet linked to a base word, re-fetches their Wiktionary/Free Dictionary API entries, and links 'form of' relationships (plurals, conjugations, participles, superlatives). After linking, marks all derived forms (rows with base_word_id) as deprecated. Includes a 100ms delay between API calls to avoid rate limiting. 5-minute timeout.","operationId":"backfillWordForms","tags":["Admin - Dictionary Enrichment"],"security":[{"sessionAuth":[]},{"bearerAuth":[]}],"parameters":[{"name":"limit","in":"query","required":false,"description":"Max words to process (1–2000, default 500). Direction-2 (API re-fetch) capped at min(limit, 200).","schema":{"type":"integer","minimum":1,"maximum":2000,"default":500}}],"responses":{"200":{"description":"Backfill results + stats","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"form_of_linked":{"type":"integer","description":"New base_word_id links established via API re-fetch"},"words_checked":{"type":"integer"},"api_errors":{"type":"integer"},"forms_deprecated":{"type":"integer","description":"Derived-form rows marked deprecated in this run"},"linked":{"type":"string","description":"Count of rows with base_word_id (bigint-as-string)"},"unlinked_enriched":{"type":"string"},"unenriched":{"type":"string"}}}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden — admin access required"},"500":{"description":"Server error during backfill"}},"x-requires-admin":true}},"/api/admin/vocabulary/deprecate-forms":{"post":{"summary":"Deprecate Derived Forms","description":"Finds all non-deprecated vocabulary rows that have a base_word_id (i.e. derived word forms) and marks them deprecated in a single UPDATE. Returns the list of deprecated forms with their parent word. Similar to /api/admin/vocabulary/dedupe-plurals but implemented as a direct SQL pass (dedupe-plurals delegates to a service helper with broader scope).","operationId":"deprecateDerivedForms","tags":["Admin - Dictionary Enrichment"],"security":[{"sessionAuth":[]},{"bearerAuth":[]}],"responses":{"200":{"description":"Deprecation results","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"deprecated_count":{"type":"integer"},"deprecated_words":{"type":"array","items":{"type":"object","properties":{"word":{"type":"string"},"parent":{"type":"string"}}}}}},"example":{"success":true,"deprecated_count":2,"deprecated_words":[{"word":"cats","parent":"cat"},{"word":"running","parent":"run"}]}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden — admin access required"},"500":{"description":"Server error"}},"x-requires-admin":true}},"/api/admin/vocabulary/dedupe-plurals":{"post":{"summary":"Deprecate Plural Duplicates","description":"Deprecates vocabulary entries that are derived forms of other words (have base_word_id set). For example, if 'derelicts' has base_word_id pointing to 'derelict', 'derelicts' is deprecated.","operationId":"deprecatePluralForms","tags":["Admin - Dictionary Enrichment"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"responses":{"200":{"description":"Deprecation results","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"deprecated_count":{"type":"integer"},"deprecated_words":{"type":"array","items":{"type":"object","properties":{"word":{"type":"string"},"parent":{"type":"string"}}}}}},"example":{"success":true,"deprecated_count":2,"deprecated_words":[{"word":"derelicts","parent":"derelict"},{"word":"abandoning","parent":"abandon"}]}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin access required"},"500":{"description":"Internal server error"}},"x-requires-admin":true}},"/api/admin/ai-helpers/etymology-random":{"post":{"summary":"Generate Etymology (Random or by ID)","description":"Generate AI etymologies for words that have dictionary data but no etymology yet, or for a specific word by ID. When word_id is provided, processes just that word. Otherwise picks random words ordered by interest score. Uses Anthropic API credits. Stops on first error. Returns INVALID for non-English words.","operationId":"generateEtymologyRandom","tags":["Admin - Dictionary Enrichment"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AiBatchBody"},"example":{}}}},"responses":{"200":{"description":"Etymology generation results","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"error":{"type":"boolean"},"error_message":{"type":"string"},"error_word":{"type":"string"},"processed":{"type":"integer"},"remaining":{"type":"integer"},"results":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"word":{"type":"string"},"etymology":{"type":"string"},"invalid":{"type":"boolean","description":"True if the AI flagged this as not a real English word"}}}}}},"example":{"success":true,"error":false,"processed":1,"remaining":312,"results":[{"id":"123e4567-e89b-12d3-a456-426614174000","word":"ephemeral","etymology":"From Greek ephēmeros, meaning 'lasting only a day'..."}]}}}},"400":{"description":"Validation error — invalid request body/params/query"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin access required"},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":true}}}}},"x-requires-admin":true}},"/api/public/word/{word}":{"get":{"summary":"Get Public Word Data","description":"Returns full word data for public display by word text (case-insensitive). Only returns rows where public_ready = true AND deprecated = false — any URL served by /api/public/sitemap or /api/public/sitemap/:chunk is guaranteed to resolve here. 404 response is identical for 'word does not exist' and 'word is not public', so private vocabulary cannot be enumerated. No authentication required. IP-rate-limited (300 requests per 15 minutes).","operationId":"getPublicWord","tags":["Public"],"security":[],"parameters":[{"name":"word","in":"path","required":true,"description":"The word to look up (e.g. 'manifestly'). Case-insensitive. Max 100 characters.","schema":{"type":"string","maxLength":100}}],"responses":{"200":{"description":"Word data","content":{"application/json":{"schema":{"type":"object","required":["id","word","category","difficulty","frequency","pronunciation_ipa","pronunciation_phonetic","etymology","synonyms","antonyms","form_tags","voice","definitions","definition_count","base_word","related_forms"],"properties":{"id":{"type":"string","description":"UUID of the vocabulary row"},"word":{"type":"string","description":"The canonical word in DB casing"},"category":{"type":"string","description":"Part of speech (noun, verb, adjective, adverb, …)"},"difficulty":{"type":"integer","nullable":true,"description":"1–100 difficulty score"},"frequency":{"type":"number","nullable":true,"description":"Float frequency score (higher = more common)"},"pronunciation_ipa":{"type":"string","nullable":true,"description":"IPA notation from Free Dictionary API"},"pronunciation_phonetic":{"type":"string","nullable":true,"description":"Simple syllable-stress form (e.g. 'uh-NO-pee-uh')"},"etymology":{"type":"string","nullable":true},"synonyms":{"type":"array","items":{"type":"string"},"description":"Always present; empty array if none"},"antonyms":{"type":"array","items":{"type":"string"}},"form_tags":{"type":"array","items":{"type":"string"},"description":"Grammatical tags like ['past', 'participle'] when this word is a derived form"},"voice":{"type":"string","nullable":true,"description":"OpenAI TTS voice preset used for speech playback"},"definitions":{"type":"array","items":{"type":"object","required":["definition","source"],"properties":{"definition":{"type":"string"},"source":{"type":"string","description":"e.g. 'ai', 'freedictionary'"},"example":{"type":"string","nullable":true,"description":"Example sentence from definition_data.example, if present"}}}},"definition_count":{"type":"integer","description":"Always equals definitions.length"},"base_word":{"type":"object","nullable":true,"description":"Populated only when this word is a derived form (e.g. 'jumping' → 'jump') AND the base word is also public_ready. Null otherwise.","required":["id","word"],"properties":{"id":{"type":"string"},"word":{"type":"string"}}},"related_forms":{"type":"array","description":"Sibling forms (if this word has a base_word) or derived child forms (if this word IS a base). Every entry is itself public_ready, so its URL is guaranteed to resolve.","items":{"type":"object","required":["word","tags"],"properties":{"word":{"type":"string"},"tags":{"type":"array","items":{"type":"string"}}}}}}},"example":{"id":"01c0755d-34ef-4a4c-af0a-4281b2e92147","word":"anopia","category":"noun","difficulty":78,"frequency":0.001471,"pronunciation_ipa":null,"pronunciation_phonetic":"uh-NO-pee-uh","etymology":"The term \"anopia\" finds its roots in the field of medicine…","synonyms":["blindness","sightlessness"],"antonyms":["sight"],"form_tags":[],"voice":"ballad","definitions":[{"definition":"Absence of normal vision in one or both eyes.","source":"ai","example":null}],"definition_count":3,"base_word":null,"related_forms":[]}}}},"400":{"description":"Word is empty or exceeds 100 characters"},"404":{"description":"Word not found, not public_ready, or deprecated (identical response — no existence leak)"},"429":{"description":"Rate limit exceeded (300 / 15 min per IP)","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Server error fetching word data"}}}},"/api/public/sitemap":{"get":{"summary":"Sitemap Index","description":"Returns an XML sitemap index with dynamically chunked sub-sitemaps (~5,000 words each). Chunks are based on two-letter word prefixes grouped into ranges (e.g. aa-af, ag-am). Cached for 5 minutes. No authentication required.","operationId":"getPublicSitemapIndex","tags":["Public"],"security":[],"responses":{"200":{"description":"XML sitemap index","content":{"application/xml":{"schema":{"type":"string"},"example":"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n  <sitemap>\n    <loc>https://lexflexer.com/api/public/sitemap/aa-af</loc>\n    <lastmod>2026-04-07</lastmod>\n  </sitemap>\n</sitemapindex>"}}},"429":{"description":"Rate limit exceeded"},"500":{"description":"Internal server error"}}}},"/api/public/sitemap/{chunk}":{"get":{"summary":"Sitemap Chunk","description":"Returns an XML sitemap for words within a prefix range. The chunk parameter is a two-letter prefix (e.g. 'ab') or a range (e.g. 'aa-af'). Cached for 5 minutes. No authentication required.","operationId":"getPublicSitemapChunk","tags":["Public"],"security":[],"parameters":[{"name":"chunk","in":"path","required":true,"description":"Prefix or prefix range (e.g. 'ab', 'aa-af'). Must match pattern: 1-2 lowercase alphanumeric chars, optionally followed by a hyphen and another 1-2 chars.","schema":{"type":"string","pattern":"^[a-z0-9]{1,2}(-[a-z0-9]{1,2})?$"}}],"responses":{"200":{"description":"XML sitemap for the chunk","content":{"application/xml":{"schema":{"type":"string"},"example":"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n  <url>\n    <loc>https://lexflexer.com/lexicon/aberrant</loc>\n    <lastmod>2026-03-30</lastmod>\n    <changefreq>monthly</changefreq>\n  </url>\n</urlset>"}}},"400":{"description":"Invalid chunk format"},"429":{"description":"Rate limit exceeded"},"500":{"description":"Internal server error"}}}},"/api/public/words":{"get":{"summary":"Browse Words","description":"Browse public vocabulary filtered by category, difficulty range, and sorted by a word-interest dimension. Returns up to 50 words per page. Only includes words where public_ready = true AND deprecated = false, with a word_interest score > 0 for the chosen sort dimension. Cached for 5 minutes. No authentication required. IP-rate-limited (300 requests per 15 minutes).","operationId":"getPublicWords","tags":["Public"],"security":[],"parameters":[{"name":"category","in":"query","required":false,"description":"Filter by word category. If omitted or invalid, all categories are returned.","schema":{"type":"string","enum":["verb","noun","adjective","adverb"]}},{"name":"difficulty_min","in":"query","required":false,"description":"Minimum difficulty (1-100). Defaults to 1.","schema":{"type":"integer","minimum":1,"maximum":100,"default":1}},{"name":"difficulty_max","in":"query","required":false,"description":"Maximum difficulty (1-100). Defaults to 100.","schema":{"type":"integer","minimum":1,"maximum":100,"default":100}},{"name":"sort","in":"query","required":false,"description":"Word-interest dimension to sort by (descending). Defaults to 'overall'.","schema":{"type":"string","enum":["overall","sound","depth","etymology","timelessness","imagery","uniqueness","story","emotion","versatility","precision"],"default":"overall"}},{"name":"offset","in":"query","required":false,"description":"Pagination offset. Defaults to 0.","schema":{"type":"integer","minimum":0,"default":0}}],"responses":{"200":{"description":"Paginated word list","content":{"application/json":{"schema":{"type":"object","properties":{"words":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"word":{"type":"string"},"category":{"type":"string"},"difficulty":{"type":"integer"},"definition":{"type":"string","nullable":true}}}},"count":{"type":"integer","description":"Number of words returned in this page"},"offset":{"type":"integer"},"limit":{"type":"integer"}}},"example":{"words":[{"id":"a1b2c3d4-...","word":"ephemeral","category":"adjective","difficulty":62,"definition":"Lasting for a very short time."}],"count":50,"offset":0,"limit":50}}}},"429":{"description":"Rate limit exceeded"},"500":{"description":"Internal server error"}}}},"/api/speech/{id}":{"get":{"summary":"Get Audio","description":"Stream stored audio pronunciation for a vocabulary word. Returns binary audio data (48kHz stereo WAV) with appropriate Content-Type header.","operationId":"getSpeechById","tags":["Speech"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"$ref":"#/components/parameters/id"}],"responses":{"200":{"description":"Binary audio data","content":{"audio/*":{"schema":{"type":"string","format":"binary"}}}},"401":{"description":"Unauthorized - Authentication required or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"404":{"description":"Audio not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Audio not found"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to stream audio"}}}}}}},"/api/speech/play-realtime-audio":{"post":{"summary":"Generate Realtime Audio","description":"Generate text-to-speech audio on the fly using OpenAI. Raw audio is normalized to 48kHz stereo before processing. Returns chunked WAV audio stream.","operationId":"createSpeechPlayRealtimeAudio","tags":["Speech"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["text"],"properties":{"text":{"type":"string","description":"Text to convert to speech"},"voice":{"type":"string","default":"alloy","description":"OpenAI TTS voice"},"speed":{"type":"number","default":1,"description":"Playback speed"},"warmup_word":{"type":"string","description":"Warmup word for audio processing"},"delimiter":{"type":"string","default":"|||","description":"Delimiter for splitting text"},"manual_trim_start":{"type":"number","default":0,"description":"Manual trim offset in seconds"}}},"example":{"text":"ephemeral","voice":"alloy","speed":1}}}},"responses":{"200":{"description":"Chunked WAV audio stream (48kHz stereo)","content":{"audio/wav":{"schema":{"type":"string","format":"binary"}}}},"400":{"description":"Missing text","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Text is required"}}}},"401":{"description":"Unauthorized - Authentication required or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to generate audio"}}}}}}},"/api/speech/{id}/test-audio":{"get":{"summary":"Test Audio","description":"Stream stored audio for testing/preview purposes (48kHz stereo WAV). Same as GET /api/speech/{id} but intended for admin testing.","operationId":"getSpeechTestAudio","tags":["Speech"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"$ref":"#/components/parameters/id"}],"responses":{"200":{"description":"Binary audio data","content":{"audio/*":{"schema":{"type":"string","format":"binary"}}}},"401":{"description":"Unauthorized - Authentication required or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}},"404":{"description":"Audio not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Audio not found"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to test audio"}}}}}}},"/api/speech/excerpt/{id}":{"post":{"summary":"Generate Excerpt TTS Audio","description":"Generates TTS audio for an excerpt using OpenAI, uploads to R2, and sets r2_audio=true. Returns speech-relevant fields only.","operationId":"postExcerptSpeech","tags":["Speech"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"$ref":"#/components/parameters/id"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"voice":{"type":"string","enum":["alloy","ash","ballad","echo","fable","marin","cedar","onyx","nova","sage","shimmer","verse"],"default":"coral","description":"TTS voice to use. Defaults to 'coral'."}}}}}},"responses":{"200":{"description":"Audio generated and uploaded. Returns speech-relevant fields.","content":{"application/json":{"schema":{"type":"object","required":["id","list_id","audio_url","r2_audio"],"properties":{"id":{"type":"string","format":"uuid"},"list_id":{"type":"string","format":"uuid"},"audio_url":{"type":"string","description":"R2 URL for the generated excerpt audio"},"r2_audio":{"type":"boolean","nullable":true}}}}}},"400":{"description":"Excerpt has no text or text exceeds 4096 characters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Excerpt not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"R2 storage not configured","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"summary":"Delete Excerpt TTS Audio","description":"Deletes TTS audio from R2 for an excerpt and sets r2_audio=false. Returns the updated ExcerptBase object.","operationId":"deleteExcerptSpeech","tags":["Speech"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"$ref":"#/components/parameters/id"}],"responses":{"200":{"description":"Audio deleted. Returns the updated excerpt.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"list_id":{"type":"string","format":"uuid"},"text":{"type":"string"},"audio_url":{"type":"string"},"image_url":{"type":"string"},"video_url":{"type":"string"},"show_excerpt":{"type":"boolean"},"is_glossary":{"type":"boolean"},"r2_audio":{"type":"boolean","nullable":true},"sort_order":{"type":"integer"},"date_created":{"type":"string","format":"date-time"},"text_sentences":{"type":"array","items":{"type":"string"},"description":"Text split into sentences"},"questions_count":{"type":"integer"}}}}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/speech/list/{id}":{"post":{"summary":"Generate Chapter TTS Audio","description":"Concatenates all excerpt texts in a chapter, generates TTS audio via OpenAI, uploads to R2, and sets r2_audio=true on the list. Returns speech-relevant fields only.","operationId":"postChapterSpeech","tags":["Speech"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Chapter (list) ID"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"voice":{"type":"string","description":"OpenAI TTS voice (default: coral)","example":"coral"}}}}}},"responses":{"200":{"description":"Audio generated and uploaded. Returns speech-relevant fields.","content":{"application/json":{"schema":{"type":"object","required":["id","name","audio_url","duration_seconds","r2_audio"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"audio_url":{"type":"string","description":"R2 URL for the generated chapter audio"},"duration_seconds":{"type":"number","description":"Audio duration in seconds"},"r2_audio":{"type":["boolean","null"]}}}}}},"400":{"description":"Not a chapter, no excerpts, or excerpts have no text"},"404":{"description":"Chapter not found"},"503":{"description":"R2 storage not configured"}}},"delete":{"summary":"Delete Chapter Audio","description":"Deletes the chapter's audio from R2 and sets r2_audio=false.","operationId":"deleteChapterSpeech","tags":["Speech"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Chapter (list) ID"}],"responses":{"200":{"description":"Audio deleted. Returns updated list object."},"404":{"description":"Chapter not found"}}}},"/api/docs/login":{"get":{"summary":"API Docs Login Page","description":"Serves an HTML login page for accessing API documentation. No authentication required.","operationId":"getDocsLogin","tags":["Docs"],"security":[],"responses":{"200":{"description":"HTML login page","content":{"text/html":{"schema":{"type":"string"}}}}}}},"/api/docs":{"get":{"summary":"API Documentation Page","description":"Serves the API documentation HTML page. Access is conditional: public in development, requires admin auth in production (unless ALLOW_PUBLIC_DOCS is set).","operationId":"getDocs","tags":["Docs"],"responses":{"200":{"description":"HTML documentation page showing base URL and link to OpenAPI spec","content":{"text/html":{"schema":{"type":"string"}}}},"401":{"description":"Unauthorized (production only, when public docs disabled)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}}}}},"/swagger.json":{"get":{"summary":"OpenAPI Specification","description":"Serves the OpenAPI 3.0 specification with dynamic server URL based on NODE_ENV. Access is conditional: public in development, requires admin auth in production (unless ALLOW_PUBLIC_DOCS is set).","operationId":"getSwaggerJson","tags":["Docs"],"responses":{"200":{"description":"OpenAPI 3.0 specification JSON","content":{"application/json":{"schema":{"type":"object","description":"Complete OpenAPI 3.0 specification"}}}},"401":{"description":"Unauthorized (production only, when public docs disabled)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Authentication required"}}}}}}},"/api/speech/question/{id}":{"post":{"summary":"Generate Question TTS Audio","description":"Generates TTS audio from the question text + answer, uploads to R2, and sets r2_audio=true on the question.","operationId":"postQuestionSpeech","tags":["Speech"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"voice":{"type":"string","default":"coral"}}}}}},"responses":{"200":{"description":"Audio generated and uploaded","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"excerpt_id":{"type":"string","format":"uuid"},"audio_url":{"type":"string"},"r2_audio":{"type":"boolean"}}}}}},"404":{"description":"Question not found"},"503":{"description":"R2 not configured"}},"x-requires-admin":true},"delete":{"summary":"Delete Question TTS Audio","description":"Deletes question audio from R2 and sets r2_audio=false.","operationId":"deleteQuestionSpeech","tags":["Speech"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Audio deleted, returns updated question"},"500":{"description":"Delete failed"}},"x-requires-admin":true}},"/api/auth/jwt/oauth":{"post":{"tags":["Authentication - JWT"],"summary":"OAuth login/signup (implicit flow — deprecated)","description":"Authenticate via Google or Facebook OAuth using an access token or ID token. Creates account if user doesn't exist. **Deprecated:** Use `/api/auth/jwt/oauth-callback` with PKCE flow instead.","deprecated":true,"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["provider","token"],"properties":{"provider":{"type":"string","enum":["google","facebook"],"description":"OAuth provider"},"token":{"type":"string","description":"OAuth ID token (Google) or access token (Facebook)"}}}}}},"responses":{"200":{"description":"Login successful (existing user)","content":{"application/json":{"schema":{"type":"object","required":["success","message","access_token","refresh_token","expires_in","is_new_user","user"],"properties":{"success":{"type":"boolean","example":true},"message":{"type":"string"},"access_token":{"type":"string"},"refresh_token":{"type":"string"},"expires_in":{"type":"integer","example":86400},"auth_session_id":{"type":"string","format":"uuid"},"analytics_session_id":{"type":["string","null"],"format":"uuid"},"is_new_user":{"type":"boolean"},"user":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string"},"min_rank":{"type":"integer"},"max_rank":{"type":"integer"},"history_length":{"type":"integer"},"list_id":{"type":["string","null"]},"book_id":{"type":["string","null"]},"role":{"type":"string"}}}}}}}},"201":{"description":"Account created (new user) — same response shape as 200"},"400":{"description":"Invalid input"},"401":{"description":"Invalid or expired token"},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal server error"}}}},"/api/auth/jwt/oauth-callback":{"post":{"tags":["Authentication - JWT"],"summary":"OAuth login/signup (PKCE flow)","description":"Authenticate via Google OAuth using the Authorization Code with PKCE flow. The frontend redirects to Google with `response_type=code`, `code_challenge`, and `code_challenge_method=S256`. Google redirects back with an authorization code in the query string. The frontend sends the code and code_verifier to this endpoint. The backend exchanges them server-to-server with Google for an ID token, then creates or finds the user account. Creates account if user doesn't exist.","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["provider","code","code_verifier","redirect_uri"],"properties":{"provider":{"type":"string","enum":["google"],"description":"OAuth provider (currently only Google supports PKCE)"},"code":{"type":"string","description":"Authorization code received from Google's redirect"},"code_verifier":{"type":"string","minLength":43,"maxLength":128,"description":"The PKCE code verifier generated by the frontend before redirecting to Google"},"redirect_uri":{"type":"string","format":"uri","description":"The exact redirect URI used in the authorization request (must match)"}}}}}},"responses":{"200":{"description":"Login successful (existing user)","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"authenticated":{"type":"boolean","example":true},"message":{"type":"string","example":"Login successful"},"access_token":{"type":"string","description":"JWT access token (24h expiry)"},"refresh_token":{"type":"string","description":"Refresh token (30 day expiry)"},"expires_in":{"type":"integer","description":"Access token lifetime in seconds","example":86400},"auth_session_id":{"type":"string","description":"Auth session tracking ID"},"analytics_session_id":{"type":["string","null"],"format":"uuid","description":"Analytics session ID for heartbeat tracking"},"is_new_user":{"type":"boolean"},"user":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string","format":"email"},"min_rank":{"type":"integer"},"max_rank":{"type":"integer"},"history_length":{"type":"integer"},"list_id":{"type":["string","null"]},"book_id":{"type":["string","null"]},"role":{"type":"string"}}}}}}}},"201":{"description":"Account created (new user) — same response shape as 200"},"400":{"description":"Invalid input (missing fields, invalid code_verifier length, invalid redirect_uri)"},"401":{"description":"Authentication failed (invalid code, code_verifier mismatch, email not verified)"},"429":{"description":"Rate limited"},"500":{"description":"Internal server error"}}}},"/api/books/{id}":{"get":{"summary":"Get full book for reader display","description":"Returns a lightweight, nested book object with all chapters and their excerpts. Designed for the book reader frontend — contains only the fields needed for rendering (no test progress, no questions, no audio data).","tags":["Books"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Book ID"}],"responses":{"200":{"description":"Full book with nested chapters and excerpts","content":{"application/json":{"schema":{"type":"object","required":["id","name","description","image_url","author","chapters"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":["string","null"]},"image_url":{"type":"string"},"author":{"type":"string"},"chapters":{"type":"array","items":{"type":"object","required":["id","name","sort_order","r2_audio","is_readable","excerpts"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"sort_order":{"type":"integer"},"r2_audio":{"type":["boolean","null"]},"is_readable":{"type":"boolean"},"excerpts":{"type":"array","items":{"type":"object","required":["id","text","image_url","r2_audio","sort_order","is_glossary"],"properties":{"id":{"type":"string","format":"uuid"},"text":{"type":"string"},"image_url":{"type":"string"},"r2_audio":{"type":"boolean"},"sort_order":{"type":"integer"},"is_glossary":{"type":"boolean"}}}}}}}}}}}},"400":{"description":"Invalid UUID"},"404":{"description":"Book not found or not accessible"}}}},"/api/blog":{"get":{"summary":"List Published Blog Posts","description":"Returns a paginated list of published blog posts, ordered by publication date (newest first). No authentication required. Does not include the full html_body or structured_data fields — use the slug endpoint for full post content.","tags":["Blog"],"operationId":"listPublishedBlogPosts","security":[],"parameters":[{"name":"limit","in":"query","description":"Number of posts to return (1-100, default 20)","schema":{"type":"integer","minimum":1,"maximum":100,"default":20}},{"name":"offset","in":"query","description":"Number of posts to skip for pagination","schema":{"type":"integer","minimum":0,"default":0}}],"responses":{"200":{"description":"Paginated list of published blog posts","content":{"application/json":{"schema":{"type":"object","properties":{"posts":{"type":"array","items":{"$ref":"#/components/schemas/BlogPost"}},"total":{"type":"integer","description":"Total number of published posts"},"limit":{"type":"integer"},"offset":{"type":"integer"}}}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/blog/{slug}":{"get":{"summary":"Get Published Blog Post by Slug","description":"Returns a single published blog post by its URL slug. No authentication required. Returns full post content including html_body and structured_data.","tags":["Blog"],"operationId":"getBlogPostBySlug","security":[],"parameters":[{"name":"slug","in":"path","required":true,"description":"URL-friendly slug of the blog post (e.g., 'how-to-improve-vocabulary')","schema":{"type":"string","pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$"}}],"responses":{"200":{"description":"Blog post found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BlogPost"}}}},"400":{"description":"Invalid slug format","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Blog post not found or not published","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/blog/admin":{"get":{"summary":"List All Blog Posts (Admin)","description":"Returns a paginated list of all blog posts including drafts. Admin only.","tags":["Blog"],"operationId":"listAllBlogPosts","security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"limit","in":"query","description":"Number of posts to return (1-100, default 20)","schema":{"type":"integer","minimum":1,"maximum":100,"default":20}},{"name":"offset","in":"query","description":"Number of posts to skip for pagination","schema":{"type":"integer","minimum":0,"default":0}}],"responses":{"200":{"description":"Paginated list of all blog posts","content":{"application/json":{"schema":{"type":"object","properties":{"posts":{"type":"array","items":{"$ref":"#/components/schemas/BlogPost"}},"total":{"type":"integer","description":"Total number of posts (including drafts)"},"limit":{"type":"integer"},"offset":{"type":"integer"}}}}}},"401":{"description":"Not authenticated"},"403":{"description":"Not an admin"},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-requires-admin":true},"post":{"summary":"Create Blog Post","description":"Creates a new blog post. Admin only. Slug is auto-generated from title if not provided. Setting published=true will set date_published automatically.","tags":["Blog"],"operationId":"createBlogPost","security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BlogPostInput"}}}},"responses":{"201":{"description":"Blog post created","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/BlogPost"},{"type":"object","properties":{"success":{"type":"boolean","example":true}},"required":["success"]}]}}}},"400":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated"},"403":{"description":"Not an admin"},"409":{"description":"Slug already exists","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-requires-admin":true}},"/api/blog/admin/{id}":{"get":{"summary":"Get Blog Post by ID (Admin)","description":"Returns any blog post by its UUID, including drafts. Admin only.","tags":["Blog"],"operationId":"getBlogPostById","security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"Blog post UUID","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Blog post found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BlogPost"}}}},"400":{"description":"Invalid ID format","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated"},"403":{"description":"Not an admin"},"404":{"description":"Blog post not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-requires-admin":true},"patch":{"summary":"Update Blog Post","description":"Partially updates a blog post. Admin only. Only provided fields are updated. Setting published=true for the first time will automatically set date_published. The date_updated field is always set to the current time.","tags":["Blog"],"operationId":"updateBlogPost","security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"Blog post UUID","schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BlogPostInput"}}}},"responses":{"200":{"description":"Blog post updated","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/BlogPost"},{"type":"object","properties":{"success":{"type":"boolean","example":true}},"required":["success"]}]}}}},"400":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated"},"403":{"description":"Not an admin"},"404":{"description":"Blog post not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Slug already exists","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-requires-admin":true},"delete":{"summary":"Delete Blog Post","description":"Permanently deletes a blog post. Admin only.","tags":["Blog"],"operationId":"deleteBlogPost","security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"Blog post UUID","schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Blog post deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"message":{"type":"string"},"id":{"type":"string","format":"uuid"}}}}}},"400":{"description":"Invalid ID format","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated"},"403":{"description":"Not an admin"},"404":{"description":"Blog post not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-requires-admin":true}},"/api/marketplacelists":{"get":{"summary":"Admin: browse marketplace lists","description":"Returns all marketplace lists with optional filters. Admin only.","operationId":"adminBrowseMarketplaceLists","tags":["Marketplace"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":20,"maximum":100},"description":"Number of results per page"},{"name":"offset","in":"query","schema":{"type":"integer","default":0},"description":"Pagination offset"},{"name":"difficulty","in":"query","schema":{"type":"string","enum":["Rookie","Explorer","Challenger","Mastermind","Genius","Unranked"]},"description":"Filter by difficulty level"},{"name":"type","in":"query","schema":{"type":"string","enum":["vocab","book","chapter"]},"description":"Filter by list content type"},{"name":"published","in":"query","schema":{"type":"string","enum":["true","false"]},"description":"Filter by published status. Omit to return all lists."},{"name":"parent_id","in":"query","schema":{"type":"string"},"description":"Filter by parent list. Pass a UUID to get children of that list, or \"null\" to get top-level lists only. Omit to return all."}],"responses":{"200":{"description":"Paginated list of marketplace lists","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MarketplaceListBrowseResponse"}}}},"400":{"description":"Invalid filter value (difficulty, type)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized — authentication required"},"403":{"description":"Forbidden — admin access required"},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"summary":"Create a new marketplace list","operationId":"createMarketplaceList","tags":["Marketplace"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["name","type"],"properties":{"name":{"type":"string","description":"List name"},"type":{"type":"string","enum":["vocab","book","chapter"],"description":"List content type"},"description":{"type":"string","description":"List description"},"difficulty_level":{"type":"string","enum":["Rookie","Explorer","Challenger","Mastermind","Genius","Unranked"]},"author":{"type":"string"},"image_url":{"type":"string"},"parent_id":{"type":"string","format":"uuid","description":"Parent book list ID. Required for type=chapter, forbidden for other types. Immutable after creation."}}}}}},"responses":{"201":{"description":"Marketplace list created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/List"}}}},"400":{"description":"Validation error — name and type are required","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated"},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/marketplacelists/{id}":{"get":{"summary":"View marketplace list details with word preview","operationId":"getMarketplaceListById","tags":["Marketplace"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Marketplace list ID"}],"responses":{"200":{"description":"Marketplace list details","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":"string","nullable":true},"is_marketplace":{"type":"boolean"},"published":{"type":"boolean"},"image_url":{"type":"string"},"difficulty_level":{"type":"string"},"author":{"type":"string"},"tags":{"type":"array","items":{"type":"string"},"nullable":true},"type":{"type":"string","enum":["vocab","book","chapter"]},"parent_id":{"type":"string","format":"uuid","nullable":true},"is_readable":{"type":"boolean"},"has_audio":{"type":"boolean"},"show_copyright":{"type":"boolean"},"skip_mastery":{"type":"boolean"},"is_wiki":{"type":"boolean"},"wiki_source_url":{"type":"string"},"copyright_information":{"type":"string"},"r2_audio":{"type":"boolean","nullable":true},"enriched":{"type":"boolean"},"sort_order":{"type":"integer"},"date_created":{"type":"string","format":"date-time"},"item_count":{"type":"integer"},"child_count":{"type":"integer"}}}}}},"400":{"description":"Invalid ID format","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Marketplace list not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"x-requires-admin":true},"delete":{"summary":"Delete a marketplace list (chapter)","description":"Deletes a marketplace chapter list and cascades to its excerpts and questions. Books cannot be deleted directly — remove chapters first. Admin only.","operationId":"deleteMarketplaceList","tags":["Marketplace"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The marketplace list ID"}],"responses":{"200":{"description":"Deleted successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"message":{"type":"string"}}}}}},"400":{"description":"Cannot delete a book directly"},"404":{"description":"Marketplace list not found"}},"x-requires-admin":true}},"/api/marketplacelists/{id}/publish_status":{"post":{"summary":"Set published status on a marketplace list","description":"Publishes or unpublishes a marketplace list. Unpublishing removes all user links (list_access rows) — users must re-link after re-publish.","operationId":"setMarketplacePublishStatus","tags":["Marketplace"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Marketplace list ID"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["published"],"properties":{"published":{"type":"boolean","description":"true to publish, false to unpublish"}}}}}},"responses":{"200":{"description":"Updated marketplace list","content":{"application/json":{"schema":{"$ref":"#/components/schemas/List"}}}},"400":{"description":"Validation error — published is required and must be a boolean","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Marketplace list not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/lists/{list_id}/link":{"post":{"summary":"Link or unlink a marketplace list from user's collection","description":"Pass `is_link: true` to add a shared reference to a marketplace list, or `is_link: false` to remove it. No data is copied — the user references the list directly.","operationId":"toggleMarketplaceLink","tags":["Userlists"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"list_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"Marketplace list ID"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["is_link"],"properties":{"is_link":{"type":"boolean","description":"true to link, false to unlink"}}}}}},"responses":{"200":{"description":"Marketplace list unlinked from collection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MessageResponse"},"example":{"message":"Marketplace list removed from your collection"}}}},"201":{"description":"Marketplace list linked to user's collection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/List"}}}},"400":{"description":"Invalid or missing fields","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Not authenticated"},"404":{"description":"Marketplace list not found or not in user's collection","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"User has already linked this list","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/subscription/plan":{"get":{"summary":"Get Current Plan","description":"Returns the user's current subscription plan type.","operationId":"getSubscriptionPlan","tags":["Subscription"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Current plan","content":{"application/json":{"schema":{"type":"object","properties":{"plan":{"type":"string","enum":["free","free-trial","paid","cancelled"]}}},"example":{"plan":"free"}}}},"401":{"description":"Unauthorized"},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal server error"}}}},"/api/subscription/checkout":{"post":{"summary":"Create Stripe Checkout Session","description":"Creates a one-time Stripe Checkout Session URL for the user to upgrade to Pro. Replaces the legacy static Payment Links flow. Blocks with 409 if the user already has an active or trialing paid subscription. Uses ENV.STRIPE_PRICE_ID (required) and optional ENV.STRIPE_SUCCESS_URL / STRIPE_CANCEL_URL / STRIPE_TRIAL_DAYS. Frontend should redirect the user to the returned URL.","operationId":"createSubscriptionCheckout","tags":["Subscription"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Checkout session URL","content":{"application/json":{"schema":{"type":"object","required":["success","url"],"properties":{"success":{"type":"boolean","example":true},"url":{"type":"string","format":"uri","description":"Stripe-hosted checkout URL (one-time use)"}}},"example":{"success":true,"url":"https://checkout.stripe.com/c/pay/cs_test_xxx"}}}},"401":{"description":"Unauthorized — JWT required"},"409":{"description":"User already has an active or trialing subscription","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"subscription_exists","plan":"paid"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"}}}},"500":{"description":"Server error creating session"},"502":{"description":"Stripe returned an empty/invalid checkout URL","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to create checkout session"}}}},"503":{"description":"Stripe not configured (STRIPE_PRICE_ID env missing)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Stripe checkout not configured"}}}}}}},"/api/subscription/portal":{"get":{"summary":"Get Customer Portal URL","description":"Returns the Stripe Customer Portal URL where users can manage or cancel their subscription.","operationId":"getSubscriptionPortal","tags":["Subscription"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Portal URL","content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","format":"uri"}}},"example":{"url":"https://billing.stripe.com/p/session/test_xxx"}}}},"401":{"description":"Unauthorized"},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal server error"},"503":{"description":"Customer portal not configured","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Customer portal not configured"}}}}}}},"/api/subscription/sync":{"post":{"summary":"Sync Subscription Status","description":"Force re-poll Stripe to check subscription status. Use after completing a payment to immediately update the plan. Tighter rate limit (10/15min) since it hits the Stripe API.","operationId":"syncSubscription","tags":["Subscription"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Updated plan","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"plan":{"type":"string","enum":["free","free-trial","paid","cancelled"]}}},"example":{"success":true,"plan":"paid"}}}},"401":{"description":"Unauthorized"},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal server error"}}}},"/api/profile/config":{"get":{"summary":"Get System Config","description":"Returns the current system configuration values. Available to any authenticated user.","operationId":"getSystemConfig","tags":["Profile"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"responses":{"200":{"description":"System configuration","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"fade_in_duration":{"type":"number","description":"Fade-in duration in seconds (0–5)"},"fade_out_duration":{"type":"number","description":"Fade-out duration in seconds (0–5)"},"playback_speed":{"type":"number","description":"Playback speed multiplier (0.80–1.20)"},"output_gain":{"type":"number","description":"Output gain multiplier (0.50–3.00)"},"autotrim_db":{"type":"number","description":"Silence threshold in dB (-80–0)"},"autotrim_start_buffer_ms":{"type":"number","description":"Buffer before speech in ms (0–500)"},"autotrim_end_buffer_ms":{"type":"number","description":"Buffer after speech in ms (0–500)"},"show_waveform_column":{"type":"boolean","description":"Show waveform column in admin vocabulary UI"},"snip":{"type":"integer","description":"Snip value"}}},"example":{"id":"550e8400-e29b-41d4-a716-446655440000","fade_in_duration":0,"fade_out_duration":0,"playback_speed":1,"output_gain":1,"autotrim_db":-40,"autotrim_start_buffer_ms":50,"autotrim_end_buffer_ms":50,"show_waveform_column":false,"snip":0}}}},"401":{"description":"Unauthorized"},"404":{"description":"No config found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too many profile requests"},"500":{"description":"Internal server error"}}}},"/api/admin/wiki-import":{"post":{"summary":"Import Wikipedia Article as Course","description":"Fetches a Wikipedia article and creates a book with chapters (one per article section) and excerpts (parsed paragraphs). All writes are atomic — if any step fails, nothing is created. Summarization and questions are added manually afterwards.","operationId":"wikiImport","tags":["Admin"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri","description":"Wikipedia article URL (e.g. https://en.wikipedia.org/wiki/Orthohantavirus)","example":"https://en.wikipedia.org/wiki/Orthohantavirus"},"difficulty_level":{"type":"string","description":"Optional difficulty level for the created book and chapters","example":"intermediate"}}}}}},"responses":{"201":{"description":"Course created successfully","content":{"application/json":{"schema":{"type":"object","properties":{"book_id":{"type":"string","format":"uuid","description":"ID of the created book"},"title":{"type":"string","description":"Article title used as book name"},"source_url":{"type":"string","format":"uri","description":"Canonical Wikipedia URL"},"chapters":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","description":"Chapter name (Wikipedia section title)"},"excerpt_count":{"type":"integer","description":"Number of excerpts created in this chapter"}}}},"total_excerpts":{"type":"integer","description":"Total excerpts created across all chapters"}}}}}},"400":{"description":"Invalid request (not a Wikipedia URL)"},"422":{"description":"Article has no usable content sections"},"500":{"description":"Import failed (fetch error, parse error, or DB error)"}}}},"/api/admin/config":{"patch":{"summary":"Update System Config","description":"Update one or more system configuration values. Only provided fields are updated. Admin only.","operationId":"updateSystemConfig","tags":["Admin"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"fade_in_duration":{"type":"number","minimum":0,"maximum":5,"description":"Fade-in duration in seconds"},"fade_out_duration":{"type":"number","minimum":0,"maximum":5,"description":"Fade-out duration in seconds"},"playback_speed":{"type":"number","minimum":0.8,"maximum":1.2,"description":"Playback speed multiplier"},"output_gain":{"type":"number","minimum":0.5,"maximum":3,"description":"Output gain multiplier"},"autotrim_db":{"type":"number","minimum":-80,"maximum":0,"description":"Silence threshold in dB"},"autotrim_start_buffer_ms":{"type":"number","minimum":0,"maximum":500,"description":"Buffer before speech in ms"},"autotrim_end_buffer_ms":{"type":"number","minimum":0,"maximum":500,"description":"Buffer after speech in ms"},"show_waveform_column":{"type":"boolean","description":"Show waveform column in admin vocabulary UI"},"snip":{"type":"integer","minimum":0,"description":"Snip value"}}},"example":{"playback_speed":1.1,"output_gain":1.5}}}},"responses":{"200":{"description":"Updated configuration","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"id":{"type":"string","format":"uuid"},"fade_in_duration":{"type":"number"},"fade_out_duration":{"type":"number"},"playback_speed":{"type":"number"},"output_gain":{"type":"number"},"autotrim_db":{"type":"number"},"autotrim_start_buffer_ms":{"type":"number"},"autotrim_end_buffer_ms":{"type":"number"},"show_waveform_column":{"type":"boolean"},"snip":{"type":"integer"}}}}}},"400":{"description":"Validation error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"playback_speed: Number must be less than or equal to 1.20"}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin access required"},"404":{"description":"No config found to update","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error"}},"x-requires-admin":true}},"/api/admin/r2/upload-batch":{"post":{"summary":"Upload Confirmed Audio to R2","description":"Batch uploads confirmed audio (audio_confirmed=true) from database bytea to Cloudflare R2. Processes up to 500 words per call. For each word: uploads to R2, then NULLs out audio_data from DB. Requires R2 environment variables to be configured.","operationId":"r2UploadBatch","tags":["Admin - R2 Audio"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"responses":{"200":{"description":"Upload results","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"total":{"type":"integer"},"uploaded":{"type":"integer"},"failed":{"type":"integer"},"failures":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"word":{"type":"string"},"error":{"type":"string"}}}}}}}}},"400":{"description":"Validation error — invalid request body/params/query"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin access required"},"413":{"description":"Payload too large"},"500":{"description":"Internal server error"},"503":{"description":"R2 not configured"}},"x-requires-admin":true}},"/api/admin/r2/verify-batch":{"post":{"summary":"Audio Health Audit","description":"Cycles through up to 1000 words not checked in the last 30 days and verifies audio state is internally consistent. For R2 words: HEAD-checks the file, fixes DB flags if inconsistent, fully resets if unreachable. For local audio: ensures flags are correct. For words claiming audio with no source: fully resets. No request body required.","operationId":"r2VerifyBatch","tags":["Admin - R2 Audio"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"responses":{"200":{"description":"Audit results","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"total":{"type":"integer","description":"Number of words audited in this batch"},"ok":{"type":"integer","description":"Words with consistent audio state"},"fixed":{"type":"integer","description":"Words with inconsistent flags that were corrected"},"reset":{"type":"integer","description":"Words with broken/missing audio that were fully reset"},"remaining":{"type":"integer","description":"Number of words still needing audit"},"actions":{"type":"array","description":"Details of fixes and resets (max 100)","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"word":{"type":"string"},"action":{"type":"string"}}}}}}}}},"400":{"description":"Validation error — invalid request body/params/query"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin access required"},"413":{"description":"Payload too large"},"500":{"description":"Internal server error"},"503":{"description":"External service unavailable or not configured"}},"x-requires-admin":true}},"/api/admin/r2/delete":{"post":{"summary":"Delete Audio from R2","description":"Deletes audio files from R2 for the specified word IDs and resets the r2_audio flag to NULL.","operationId":"r2Delete","tags":["Admin - R2 Audio"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["ids"],"properties":{"ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":200,"description":"Word IDs to delete audio for"}}},"example":{"ids":["uuid-1","uuid-2"]}}}},"responses":{"200":{"description":"Deletion results","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":true},"deleted":{"type":"integer"},"failed":{"type":"integer"},"failures":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"error":{"type":"string"}}}}}}}}},"400":{"description":"Invalid request - ids array required, max 200"},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin access required"},"500":{"description":"Internal server error"},"503":{"description":"R2 not configured"}},"x-requires-admin":true}},"/api/admin/r2/status":{"get":{"summary":"R2 Migration Status","description":"Returns counts of audio in various states: pending upload, on R2 (verified/failed), local audio remaining, and total local audio size.","operationId":"r2Status","tags":["Admin - R2 Audio"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"responses":{"200":{"description":"Migration status","content":{"application/json":{"schema":{"type":"object","properties":{"pending_upload":{"type":"integer","description":"Confirmed audio not yet on R2"},"on_r2":{"type":"integer","description":"Audio on R2 and verified"},"r2_failed":{"type":"integer","description":"Audio marked as failed on R2"},"has_local_audio":{"type":"integer","description":"Words with bytea audio_data in DB"},"confirmed_total":{"type":"integer","description":"Total confirmed audio"},"audio_exists_total":{"type":"integer","description":"Total words with audio_exists=true"},"local_audio_size":{"type":"string","description":"Human-readable size of local audio data"}}}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin access required"},"500":{"description":"Internal server error"}},"x-requires-admin":true}},"/api/admin/stats/summary":{"get":{"summary":"Dashboard Summary","description":"Combined summary for dashboard cards: online user count, unique visitors (1d/7d), total registered users, and average session duration (7d).","operationId":"adminStatsSummary","tags":["Admin - Stats"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"responses":{"200":{"description":"Summary stats","content":{"application/json":{"schema":{"type":"object","properties":{"onlineCount":{"type":"integer","description":"Users with active sessions in last 30 minutes"},"visitors1d":{"type":"integer","description":"Unique visitors in last 24 hours"},"visitors7d":{"type":"integer","description":"Unique visitors in last 7 days"},"totalUsers":{"type":"integer","description":"Total registered accounts"},"avgSessionSeconds":{"type":"integer","description":"Average session duration in seconds (7d)"}}}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin access required"}},"x-requires-admin":true}},"/api/admin/stats/online":{"get":{"summary":"Currently Online Users","description":"Returns users with active sessions in the last 30 minutes, merged with their latest auth session event (current action).","operationId":"adminStatsOnline","tags":["Admin - Stats"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"responses":{"200":{"description":"Online users with activity","content":{"application/json":{"schema":{"type":"object","properties":{"users":{"type":"array","items":{"type":"object","properties":{"session_id":{"type":"string"},"user_id":{"type":"string"},"email":{"type":"string"},"username":{"type":"string","nullable":true},"login_time":{"type":"string","format":"date-time"},"last_activity":{"type":"string","format":"date-time"},"ip_address":{"type":"string","nullable":true},"current_action":{"type":"string","nullable":true,"description":"Latest auth session event type"},"action_time":{"type":"string","format":"date-time","nullable":true}}}},"count":{"type":"integer"}}}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin access required"}},"x-requires-admin":true}},"/api/admin/stats/visitors":{"get":{"summary":"Unique Visitor Count","description":"Returns the count of unique users who logged in during the specified period.","operationId":"adminStatsVisitors","tags":["Admin - Stats"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"parameters":[{"name":"period","in":"query","required":false,"description":"Time period to query","schema":{"type":"string","enum":["1d","7d","30d"],"default":"7d"}}],"responses":{"200":{"description":"Visitor count","content":{"application/json":{"schema":{"type":"object","properties":{"period":{"type":"string","enum":["1d","7d","30d"]},"count":{"type":"integer"}}}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin access required"}},"x-requires-admin":true}},"/api/admin/stats/activity":{"get":{"summary":"Session Duration Stats","description":"Returns total session time, average session duration, and session count for completed sessions in the specified period.","operationId":"adminStatsActivity","tags":["Admin - Stats"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"parameters":[{"name":"period","in":"query","required":false,"description":"Time period to query","schema":{"type":"string","enum":["1d","7d","30d"],"default":"7d"}}],"responses":{"200":{"description":"Activity stats","content":{"application/json":{"schema":{"type":"object","properties":{"period":{"type":"string","enum":["1d","7d","30d"]},"totalSeconds":{"type":"integer","description":"Total session time in seconds"},"avgSeconds":{"type":"integer","description":"Average session duration in seconds"},"sessionCount":{"type":"integer","description":"Number of completed sessions"}}}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin access required"}},"x-requires-admin":true}},"/api/admin/stats/signups":{"get":{"summary":"Daily Signup Timeline","description":"Returns daily signup counts for the specified number of days. Days are clamped to 1-365.","operationId":"adminStatsSignups","tags":["Admin - Stats"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"parameters":[{"name":"days","in":"query","required":false,"description":"Number of days to look back (clamped 1-365)","schema":{"type":"integer","minimum":1,"maximum":365,"default":30}}],"responses":{"200":{"description":"Signup timeline","content":{"application/json":{"schema":{"type":"object","properties":{"days":{"type":"integer"},"timeline":{"type":"array","items":{"type":"object","properties":{"date":{"type":"string","format":"date"},"count":{"type":"integer"}}}}}}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin access required"}},"x-requires-admin":true}},"/api/admin/stats/events":{"get":{"summary":"Event Type Breakdown","description":"Returns auth session event counts grouped by event type, ordered by frequency descending.","operationId":"adminStatsEvents","tags":["Admin - Stats"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"parameters":[{"name":"period","in":"query","required":false,"description":"Time period to query","schema":{"type":"string","enum":["1d","7d","30d"],"default":"7d"}}],"responses":{"200":{"description":"Event breakdown","content":{"application/json":{"schema":{"type":"object","properties":{"period":{"type":"string","enum":["1d","7d","30d"]},"events":{"type":"array","items":{"type":"object","properties":{"eventType":{"type":"string"},"count":{"type":"integer"}}}}}}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin access required"}},"x-requires-admin":true}},"/api/admin/stats/traffic":{"get":{"summary":"Hourly API Traffic","description":"Returns request counts from api_logs grouped by hour for the specified period.","operationId":"adminStatsTraffic","tags":["Admin - Stats"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"parameters":[{"name":"period","in":"query","required":false,"description":"Time period to query","schema":{"type":"string","enum":["1d","7d","30d"],"default":"7d"}}],"responses":{"200":{"description":"Traffic data","content":{"application/json":{"schema":{"type":"object","properties":{"period":{"type":"string","enum":["1d","7d","30d"]},"traffic":{"type":"array","items":{"type":"object","properties":{"hour":{"type":"string","format":"date-time"},"count":{"type":"integer"}}}}}}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin access required"}},"x-requires-admin":true}},"/api/admin/stats/top-paths":{"get":{"summary":"Top API Paths","description":"Returns the most-hit API paths from api_logs, grouped and ordered by request count descending.","operationId":"adminStatsTopPaths","tags":["Admin - Stats"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"parameters":[{"name":"period","in":"query","required":false,"description":"Time period to query","schema":{"type":"string","enum":["1d","7d","30d"],"default":"7d"}},{"name":"limit","in":"query","required":false,"description":"Max paths to return (clamped 1-100)","schema":{"type":"integer","minimum":1,"maximum":100,"default":20}}],"responses":{"200":{"description":"Top paths","content":{"application/json":{"schema":{"type":"object","properties":{"period":{"type":"string","enum":["1d","7d","30d"]},"paths":{"type":"array","items":{"type":"object","properties":{"path":{"type":"string"},"count":{"type":"integer"}}}}}}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin access required"}},"x-requires-admin":true}},"/api/admin/stats/check-sentry-logging":{"get":{"summary":"Test Sentry Integration","description":"Fires a test error to Sentry to verify the integration is working. Returns the Sentry event ID if successful.","operationId":"adminStatsCheckSentry","tags":["Admin - Stats"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"responses":{"200":{"description":"Test error sent","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"boolean","example":false},"message":{"type":"string","example":"Test error sent to Sentry"},"eventId":{"type":"string","nullable":true,"description":"Sentry event ID (null if Sentry is disabled)"}}}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin access required"}},"x-requires-admin":true}},"/api/admin/stats/simulate-db-failure":{"post":{"summary":"Simulate DB failures for Sentry alerting","description":"Exercises the real db-error-capture → Sentry pipeline with 4 failure scenarios: statement timeout (real query), division by zero (real query), synthetic ECONNREFUSED (connection error path), and synthetic 53300 too_many_connections (resource exhaustion path). Check Sentry for 4 events after calling.","operationId":"simulateDbFailure","tags":["Admin - Stats"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"responses":{"200":{"description":"Simulation results","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"boolean","example":false},"message":{"type":"string"},"results":{"type":"array","items":{"type":"object","properties":{"test":{"type":"string"},"status":{"type":"string"},"detail":{"type":"string"}}}}}}}}}},"x-requires-admin":true}},"/api/admin/diagnostics":{"get":{"summary":"System Diagnostics","description":"Runs non-destructive, read-only health checks against every integration the app depends on (DB, Sentry, R2, Stripe, AI providers, cache, process). Each check is independent — one failure does not block the others. No secrets are exposed in the response.","operationId":"adminDiagnostics","tags":["Admin - Stats"],"security":[{"cookieAuth":[]},{"bearerAuth":[]}],"responses":{"200":{"description":"Diagnostics results","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["pass","degraded","fail"],"description":"Overall status: pass = all checks passed, degraded = some failed, fail = all failed"},"timestamp":{"type":"string","format":"date-time"},"checks":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string","enum":["db_pool","sentry","r2","stripe","anthropic","openai","cache","process"],"description":"Integration being checked"},"status":{"type":"string","enum":["pass","fail","skip"],"description":"pass = healthy, fail = unreachable or broken, skip = not configured in this environment"},"latency_ms":{"type":"number","nullable":true,"description":"Time taken for this check in milliseconds"},"detail":{"type":"string","nullable":true,"description":"Human-readable detail (pool stats, event IDs, error message, etc.)"}}}}}},"example":{"status":"pass","timestamp":"2026-05-11T10:00:00.000Z","checks":[{"name":"db_pool","status":"pass","latency_ms":1.2,"detail":"total=5 idle=3 waiting=0 max=40"},{"name":"sentry","status":"pass","latency_ms":0.5,"detail":"event_ids: {\"pool_connection_lost\":\"abc123\",\"db_connection_error\":\"def456\",\"db_resource_exhaustion\":\"ghi789\"}"},{"name":"r2","status":"pass","latency_ms":120.3,"detail":"HEAD bucket OK"},{"name":"stripe","status":"pass","latency_ms":340.1,"detail":"GET /v1/prices OK"},{"name":"anthropic","status":"pass","latency_ms":0.1,"detail":"configured"},{"name":"openai","status":"pass","latency_ms":0.1,"detail":"configured"},{"name":"cache","status":"pass","latency_ms":0,"detail":"entries=42"},{"name":"process","status":"pass","latency_ms":0.3,"detail":"{\"heap_used_mb\":85,\"heap_total_mb\":120,\"rss_mb\":150,\"uptime_s\":3600,\"node\":\"v20.11.0\",\"event_loop_lag_ms\":0.2}"}]}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden - Admin access required"}},"x-requires-admin":true}},"/api/games/words":{"get":{"summary":"Get Game Words","description":"Returns candidate vocabulary words for games. By default returns only words the user is currently learning (tested, answer_score < 1). Optionally include untested and/or mastered words.","operationId":"getGameWords","tags":["Crossword"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"limit","in":"query","required":false,"description":"Number of words to return (default 50, max 200)","schema":{"type":"integer","minimum":1,"maximum":200,"default":50}},{"name":"include_untested","in":"query","required":false,"description":"Include words the user has never tested (default false)","schema":{"type":"boolean","default":false}},{"name":"include_mastered","in":"query","required":false,"description":"Include words the user has mastered (answer_score = 1) (default false)","schema":{"type":"boolean","default":false}},{"name":"has_synonyms","in":"query","required":false,"description":"Only return words that have at least one synonym (default false)","schema":{"type":"boolean","default":false}},{"name":"has_antonyms","in":"query","required":false,"description":"Only return words that have at least one antonym (default false)","schema":{"type":"boolean","default":false}},{"name":"pattern","in":"query","required":false,"description":"PostgreSQL case-insensitive regex to filter words (e.g. '^.a....c.d.$' for crossword gap-filling). Max 100 characters.","schema":{"type":"string","maxLength":100}}],"responses":{"200":{"description":"Candidate words for games","content":{"application/json":{"schema":{"type":"object","properties":{"words":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"word":{"type":"string"},"definition":{"type":"string"},"definition_count":{"type":"integer","description":"Number of definitions for this word"},"difficulty":{"type":"integer","minimum":1,"maximum":100},"category":{"type":"string"},"synonyms":{"type":"array","items":{"type":"string"},"description":"Synonym words from the vocabulary"},"antonyms":{"type":"array","items":{"type":"string"},"description":"Antonym words from the vocabulary"}}}},"count":{"type":"integer","description":"Number of words returned"}}},"example":{"words":[{"id":"550e8400-e29b-41d4-a716-446655440000","word":"ephemeral","definition":"Lasting for a very short time","difficulty":72,"category":"adjective","synonyms":["fleeting","transient"],"antonyms":["permanent","enduring"]},{"id":"6ba7b810-9dad-11d1-80b4-00c04fd430c8","word":"ubiquitous","definition":"Present, appearing, or found everywhere","difficulty":65,"category":"adjective","synonyms":["omnipresent","pervasive"],"antonyms":["rare","scarce"]}],"count":2}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden — insufficient permissions or activity limit reached"},"404":{"description":"User not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"User not found"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch crossword words"}}}}}}},"/api/games/quotes":{"get":{"summary":"Get Game Quotes","description":"Returns quotes featuring words the user is learning. By default returns only quotes for words currently being learned (tested, answer_score < 1). Optionally include quotes for untested and/or mastered words.","operationId":"getGameQuotes","tags":["Crossword"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"limit","in":"query","required":false,"description":"Number of quotes to return (default 10, max 50)","schema":{"type":"integer","minimum":1,"maximum":50,"default":10}},{"name":"include_untested","in":"query","required":false,"description":"Include quotes for words the user has never tested (default false)","schema":{"type":"boolean","default":false}},{"name":"include_mastered","in":"query","required":false,"description":"Include quotes for words the user has mastered (answer_score = 1) (default false)","schema":{"type":"boolean","default":false}}],"responses":{"200":{"description":"Quotes featuring words the user is learning","content":{"application/json":{"schema":{"type":"object","properties":{"quotes":{"type":"array","items":{"type":"object","properties":{"quote_id":{"type":"string","format":"uuid"},"quote":{"type":"string"},"author":{"type":"string","nullable":true},"quote_type":{"type":"string","enum":["quote","example"]},"word_id":{"type":"string","format":"uuid"},"word":{"type":"string"}}}},"count":{"type":"integer","description":"Number of quotes returned"}}},"example":{"quotes":[{"quote_id":"550e8400-e29b-41d4-a716-446655440000","quote":"The man owned a dog and two cats.","author":null,"quote_type":"example","word_id":"6ba7b810-9dad-11d1-80b4-00c04fd430c8","word":"dog"}],"count":1}}}},"401":{"description":"Unauthorized"},"403":{"description":"Forbidden — insufficient permissions or activity limit reached"},"404":{"description":"User not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"User not found"}}}},"429":{"description":"Rate limit exceeded","headers":{"Retry-After":{"schema":{"type":"integer"},"description":"Seconds until next request allowed"}}},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Failed to fetch game quotes"}}}}}}},"/api/excerpts":{"post":{"summary":"Create an excerpt","description":"Creates an excerpt attached to a list (typically a chapter). An excerpt may carry text, audio, image and/or video — at least one of `text`, `audio_url`, `image_url`, `video_url` must be non-empty. The frontend renders polymorphically based on which fields are populated. Admin only.","operationId":"createExcerpt","tags":["Excerpts"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["list_id"],"properties":{"list_id":{"type":"string","format":"uuid","description":"The list (chapter) to attach the excerpt to"},"text":{"type":"string","description":"The excerpt text content. Optional — may be empty if at least one media URL is provided."},"audio_url":{"type":"string","maxLength":2000,"description":"URL to an audio file (must be http(s) when non-empty). Optional."},"image_url":{"type":"string","maxLength":2000,"description":"URL to an image (must be http(s) when non-empty). Optional."},"video_url":{"type":"string","maxLength":2000,"description":"URL to a video (must be http(s) when non-empty). Optional."},"parse_paragraphs":{"type":"boolean","default":false,"description":"When true, splits `text` by double-newline into paragraphs and creates one excerpt per paragraph, preserving order. Returns `{ count, excerpts: [...] }` instead of a single excerpt."}}},"examples":{"textOnly":{"summary":"Text-only excerpt","value":{"list_id":"5c843ee2-11ba-4e2d-a300-99a9e1f31520","text":"It is a truth universally acknowledged…"}},"parseParagraphs":{"summary":"Batch create from paragraphs","value":{"list_id":"5c843ee2-11ba-4e2d-a300-99a9e1f31520","text":"First paragraph of the chapter.\n\nSecond paragraph continues the story.\n\nThird paragraph wraps up.","parse_paragraphs":true}},"imageOnly":{"summary":"Image-only excerpt","value":{"list_id":"5c843ee2-11ba-4e2d-a300-99a9e1f31520","image_url":"https://cdn.example.com/excerpts/pemberley.jpg"}},"textWithMedia":{"summary":"Text + audio + image","value":{"list_id":"5c843ee2-11ba-4e2d-a300-99a9e1f31520","text":"Listen along while you read…","audio_url":"https://cdn.example.com/excerpts/ch1.mp3","image_url":"https://cdn.example.com/excerpts/ch1.jpg"}}}}}},"responses":{"201":{"description":"Excerpt created","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"list_id":{"type":"string","format":"uuid"},"text":{"type":"string"},"text_sentences":{"type":"array","items":{"type":"string"},"description":"Text split into sentences. Sentence indices match source_sentences on questions."},"audio_url":{"type":"string"},"image_url":{"type":"string"},"video_url":{"type":"string"},"show_excerpt":{"type":"boolean"},"is_glossary":{"type":"boolean","description":"Whether this excerpt is a glossary excerpt (auto-generated, shown separately from content excerpts)."},"r2_audio":{"type":"boolean","nullable":true,"description":"TTS audio state: true = on R2, false = failed/deleted, null = not yet attempted."},"sort_order":{"type":"integer","description":"Position for ordering. Auto-assigned on create."},"date_created":{"type":"string","format":"date-time"},"questions_count":{"type":"integer"}}}}}},"400":{"description":"Zod validation error (invalid UUID, malformed URL, or no content provided), or list type is not 'chapter'. When no field of {text, audio_url, image_url, video_url} is non-empty the response carries `error: \"Excerpt must include at least one of: text, audio_url, image_url, video_url\"`. The 400 returned for a wrong list type includes a `detail` field naming the actual type.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":false},"error":{"type":"string"},"detail":{"type":"string","description":"Present on type-mismatch 400; e.g. \"List has type='vocab', expected 'chapter'\""}}},"example":{"success":false,"error":"Excerpts can only be added to chapter lists","detail":"List has type='vocab', expected 'chapter'"}}}},"403":{"description":"List exists but is owned by another admin.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":false},"error":{"type":"string"},"detail":{"type":"string"}}},"example":{"success":false,"error":"Not the list owner","detail":"List exists but is owned by another user"}}}},"404":{"description":"List not found.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":false},"error":{"type":"string"},"detail":{"type":"string"}}},"example":{"success":false,"error":"List not found","detail":"No list exists with this ID"}}}},"500":{"description":"Repository or unexpected failure. Two shapes: (a) repository returned no row — `detail` carries the underlying DB error string; (b) catch-all unexpected exception — includes a UUID `error_id` for log correlation.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":false},"error":{"type":"string"},"detail":{"type":"string","description":"Underlying DB error or exception message"},"error_id":{"type":"string","format":"uuid","description":"Present on the catch-all path; matches the value logged server-side"}}},"examples":{"repositoryError":{"summary":"DB error surfaced from repository","value":{"success":false,"error":"Failed to create excerpt","detail":"duplicate key value violates unique constraint"}},"unexpectedException":{"summary":"Catch-all with error_id","value":{"success":false,"error":"Internal Server Error","error_id":"7c50adfa-e4f2-4442-afc9-2c062d7740f3","detail":"connection lost"}}}}}}},"x-requires-admin":true}},"/api/questions":{"post":{"summary":"Create a question","description":"Creates a comprehension question attached to an excerpt. The caller must own the excerpt's parent chapter list. Admin only.","operationId":"createQuestion","tags":["Questions"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["excerpt_id","question","answer"],"properties":{"excerpt_id":{"type":"string","format":"uuid","description":"The excerpt this question relates to"},"question":{"type":"string","minLength":1,"description":"The question text"},"answer":{"type":"string","minLength":1,"description":"The answer text"},"source_sentences":{"type":"array","items":{"type":"integer","minimum":1},"default":[],"description":"Optional 1-based sentence numbers the answer derives from"},"distractors":{"type":"array","items":{"type":"string"},"maxItems":7,"default":[],"description":"Up to 7 plausible but incorrect alternative answers for multiple-choice"}}}}}},"responses":{"201":{"description":"Question created","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"excerpt_id":{"type":"string","format":"uuid"},"question":{"type":"string"},"answer":{"type":"string"},"source_sentences":{"type":"array","items":{"type":"integer"},"description":"1-based sentence numbers from the excerpt that the answer derives from"},"distractors":{"type":"array","items":{"type":"string"},"description":"Up to 7 plausible but incorrect alternative answers for multiple-choice"},"is_glossary":{"type":"boolean","description":"Whether this question belongs to a glossary excerpt"},"is_extended":{"type":"boolean","description":"Whether this question uses knowledge beyond the excerpt text. Read-only, set by AI generation."},"r2_audio":{"type":"boolean","nullable":true,"description":"TTS audio state: true = on R2, false = failed/deleted, null = not yet attempted."},"sort_order":{"type":"integer","description":"Position for ordering. Auto-assigned on create."},"date_created":{"type":"string","format":"date-time"}}}}}},"400":{"description":"Validation error"},"403":{"description":"Caller does not own the excerpt's parent list"},"404":{"description":"Excerpt not found"}},"x-requires-admin":true}},"/api/questions/ai-generate":{"post":{"summary":"AI-generate questions from an excerpt","description":"Two-phase AI generation: Phase 1 generates comprehension questions across four categories (forward recall, reverse recall, synthesis, inference/background knowledge). Phase 2 generates companion questions (reverse angles, deeper follow-ups) for Phase 1 results. Existing questions are passed with Q+A pairs for dedup. Code-level Jaccard similarity check filters near-duplicates. Questions scored below 3/5 are discarded. Admin only, must own the excerpt's parent chapter.","operationId":"generateQuestions","tags":["Questions"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["excerpt_id"],"properties":{"excerpt_id":{"type":"string","format":"uuid","description":"The excerpt to generate questions from"}}}}}},"responses":{"201":{"description":"Questions generated and saved","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"questions":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"excerpt_id":{"type":"string","format":"uuid"},"question":{"type":"string"},"answer":{"type":"string"},"source_sentences":{"type":"array","items":{"type":"integer"},"description":"1-based sentence numbers from the excerpt that the answer derives from"},"distractors":{"type":"array","items":{"type":"string"},"description":"Up to 7 plausible but incorrect alternative answers for multiple-choice"},"date_created":{"type":"string","format":"date-time"}}}}}}}}},"400":{"description":"Validation error or excerpt text is empty"},"403":{"description":"Caller does not own the excerpt's parent list"},"404":{"description":"Excerpt not found"}},"x-requires-admin":true}},"/api/questions/ai-generate-topic":{"post":{"summary":"AI-answer a user-written question with distractors","description":"User writes their own question in the topic field; AI generates an answer (≤100 words). No distractors or source_sentences — extended questions don't use them. The excerpt text and existing questions are NOT sent to the AI — the user is fully responsible for question quality. Admin only, must own the excerpt's parent chapter.","operationId":"generateTopicQuestion","tags":["Questions"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["excerpt_id","topic"],"properties":{"excerpt_id":{"type":"string","format":"uuid","description":"The excerpt to save the question under"},"topic":{"type":"string","minLength":1,"maxLength":200,"description":"The user-written question for the AI to answer (e.g. 'What are the main types of fermions?')"}}}}}},"responses":{"201":{"description":"Question saved with AI-generated answer","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"questions":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"excerpt_id":{"type":"string","format":"uuid"},"question":{"type":"string","description":"The user-written question (echoed back from topic field)"},"answer":{"type":"string","description":"AI-generated answer (≤100 words)"},"is_glossary":{"type":"boolean"},"is_extended":{"type":"boolean","description":"Always true — extended questions have no distractors or source_sentences"},"r2_audio":{"type":"boolean","nullable":true},"sort_order":{"type":"integer"},"date_created":{"type":"string","format":"date-time"}}}},"usage":{"type":"object","nullable":true}}}}}},"400":{"description":"Validation error or topic is invalid"},"403":{"description":"Caller does not own the excerpt's parent list"},"404":{"description":"Excerpt not found"}},"x-requires-admin":true}},"/api/questions/generate-glossary":{"post":{"summary":"Generate glossary questions for a chapter","description":"Generates preparatory glossary Q&A for a chapter. Glossary questions explain concepts, terms, and background knowledge the chapter assumes. Uses sequential context accumulation: earlier chapters must be enriched first (enriched=true), and their content + glossary questions are passed as context so concepts are not repeated across chapters. Creates a hidden glossary excerpt (is_glossary=true, show_excerpt=false) to store the questions. Fails if the glossary excerpt already has questions — delete them manually before re-enriching.","operationId":"generateGlossary","tags":["Questions"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["chapter_id"],"properties":{"chapter_id":{"type":"string","format":"uuid","description":"The chapter to generate glossary questions for"}}}}}},"responses":{"200":{"description":"Chapter is self-contained — no glossary questions needed. Chapter still marked enriched=true.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"questions":{"type":"array","items":{"type":"object"}},"usage":{"type":"object","nullable":true},"message":{"type":"string"}}}}}},"201":{"description":"Glossary questions generated","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"questions":{"type":"array","items":{"type":"object"}},"glossary_excerpt_id":{"type":"string","format":"uuid"},"usage":{"type":"object","nullable":true}}}}}},"400":{"description":"Sequential enrichment violated, glossary questions already exist, or not a chapter","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"error":{"type":"string"},"unenriched":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}}},"description":"Chapters that must be enriched first"},"glossary_excerpt_id":{"type":"string","format":"uuid","description":"Returned when glossary already has questions"},"question_count":{"type":"integer","description":"Number of existing glossary questions to delete"}}}}}},"404":{"description":"Chapter not found"}},"x-requires-admin":true}},"/api/excerpts/ai-summarize":{"post":{"summary":"AI-summarize an excerpt","description":"Uses OpenAI to produce a faithful, compressed summary of an excerpt's text. Preserves chronological order, named entities, dates, numbers, and quotations verbatim; matches the source language and register; does not infer or add information. The summary is returned in the response only — it is not persisted.","operationId":"summarizeExcerpt","tags":["Excerpts"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["excerpt_id"],"properties":{"excerpt_id":{"type":"string","format":"uuid","description":"The excerpt to summarize"},"strength":{"type":"string","enum":["light","medium","strong"],"default":"medium","description":"Compression level. light ≈ 90% of original, medium ≈ 70%, strong ≈ 50%."}}}}}},"responses":{"200":{"description":"Summary generated","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"excerpt_id":{"type":"string","format":"uuid"},"strength":{"type":"string","enum":["light","medium","strong"]},"summary":{"type":"string"}}}}}},"400":{"description":"Validation error or excerpt text is empty"},"403":{"description":"Caller does not own the excerpt"},"404":{"description":"Excerpt not found"},"500":{"description":"Failed to generate summary"}},"x-requires-admin":true}},"/api/excerpts/{list_id}":{"get":{"summary":"Get excerpts for a chapter","description":"Returns all excerpts attached to a chapter list, ordered by sort_order ASC. Admin only, must own the list. For the user-facing read view, see GET /api/test/book/excerpts.","operationId":"getExcerptsByList","tags":["Excerpts"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"list_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The chapter list ID"}],"responses":{"200":{"description":"Array of excerpts","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","required":["id","user_id","list_id","text","text_sentences","audio_url","image_url","video_url","show_excerpt","r2_audio","sort_order","date_created","questions_count"],"properties":{"id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"list_id":{"type":"string","format":"uuid"},"text":{"type":"string","description":"Excerpt body text. Empty string when the excerpt is media-only."},"text_sentences":{"type":"array","items":{"type":"string"},"description":"Text split into sentences. Sentence indices match source_sentences on questions."},"audio_url":{"type":"string","description":"URL to an audio file. Empty string when not set."},"image_url":{"type":"string","description":"URL to an image. Empty string when not set."},"video_url":{"type":"string","description":"URL to a video. Empty string when not set."},"show_excerpt":{"type":"boolean","description":"Whether to display this excerpt's text during testing. Defaults to true."},"r2_audio":{"type":"boolean","nullable":true,"description":"TTS audio state: true = on R2, false = failed/deleted, null = not yet attempted."},"sort_order":{"type":"integer","description":"Position for ordering"},"date_created":{"type":"string","format":"date-time"},"questions_count":{"type":"integer","description":"Number of questions attached to this excerpt."}}}}}}},"404":{"description":"List not found"}},"x-requires-admin":true}},"/api/excerpts/{id}":{"patch":{"summary":"Update excerpt content","description":"Partially updates an excerpt's content or settings. At least one field must be present. Pass an empty string to clear a media URL. After applying the patch, the merged row must still have at least one of {text, audio_url, image_url, video_url} non-empty. Admin only, must own the excerpt.","operationId":"updateExcerpt","tags":["Excerpts"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The excerpt ID"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","minProperties":1,"properties":{"text":{"type":"string","description":"Updated excerpt text. Pass empty string to clear (only valid if a media URL remains)."},"audio_url":{"type":"string","maxLength":2000,"description":"Updated audio URL. Empty string clears the field; non-empty must be http(s)."},"image_url":{"type":"string","maxLength":2000,"description":"Updated image URL. Empty string clears the field; non-empty must be http(s)."},"video_url":{"type":"string","maxLength":2000,"description":"Updated video URL. Empty string clears the field; non-empty must be http(s)."},"show_excerpt":{"type":"boolean","description":"Whether to display the excerpt text to the user during testing. Defaults to true."}}},"examples":{"updateText":{"summary":"Update only text","value":{"text":"Revised opening paragraph…"}},"addImage":{"summary":"Attach an image to an existing text excerpt","value":{"image_url":"https://cdn.example.com/excerpts/ch1.jpg"}},"hideExcerpt":{"summary":"Hide excerpt text during testing","value":{"show_excerpt":false}}}}}},"responses":{"200":{"description":"Updated excerpt","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"list_id":{"type":"string","format":"uuid"},"text":{"type":"string"},"text_sentences":{"type":"array","items":{"type":"string"},"description":"Text split into sentences. Sentence indices match source_sentences on questions."},"audio_url":{"type":"string"},"image_url":{"type":"string"},"video_url":{"type":"string"},"show_excerpt":{"type":"boolean"},"is_glossary":{"type":"boolean","description":"Whether this excerpt is a glossary excerpt."},"r2_audio":{"type":"boolean","nullable":true},"sort_order":{"type":"integer"},"date_created":{"type":"string","format":"date-time"},"questions_count":{"type":"integer"}}}}}},"400":{"description":"Zod validation error (invalid URL or no fields supplied) or merged row would have no content remaining. The post-merge rejection carries `error: \"Excerpt must include at least one of: text, audio_url, image_url, video_url\"`.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":false},"error":{"type":"string"}}}}}},"404":{"description":"Excerpt not found, or owned by another user"}},"x-requires-admin":true},"delete":{"summary":"Delete an excerpt","description":"Deletes an excerpt by ID. Rejects with 400 if the excerpt has questions attached — delete them first. Admin only, must own the excerpt.","operationId":"deleteExcerpt","tags":["Excerpts"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The excerpt ID"}],"responses":{"200":{"description":"Deleted successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"message":{"type":"string"}}},"example":{"success":true,"message":"Excerpt deleted successfully"}}}},"400":{"description":"Excerpt has questions attached — delete them first","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":false},"error":{"type":"string"}}},"example":{"success":false,"error":"Cannot delete excerpt because it has questions attached. Delete the questions first."}}}},"404":{"description":"Excerpt not found or owned by another user"},"500":{"description":"Internal Server Error","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean","example":false},"error":{"type":"string"},"detail":{"type":"string"}}}}}}},"x-requires-admin":true}},"/api/questions/{excerpt_id}":{"get":{"summary":"Get questions for an excerpt","description":"Returns all questions attached to an excerpt. Admin only, must own the excerpt's parent chapter.","operationId":"getQuestionsByExcerpt","tags":["Questions"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"excerpt_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The excerpt ID"}],"responses":{"200":{"description":"Array of questions","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","required":["id","user_id","excerpt_id","question","answer","source_sentences","distractors","sort_order","date_created"],"properties":{"id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"excerpt_id":{"type":"string","format":"uuid"},"question":{"type":"string"},"answer":{"type":"string"},"source_sentences":{"type":"array","items":{"type":"integer"},"description":"1-based sentence numbers from the excerpt that the answer derives from"},"distractors":{"type":"array","items":{"type":"string"},"description":"Up to 7 plausible but incorrect alternative answers for multiple-choice presentation"},"sort_order":{"type":"integer","description":"Position for ordering"},"date_created":{"type":"string","format":"date-time"}}}},"example":[{"id":"550e8400-e29b-41d4-a716-446655440000","user_id":"660e8400-e29b-41d4-a716-446655440001","excerpt_id":"770e8400-e29b-41d4-a716-446655440002","question":"What is the main theme of this passage?","answer":"The importance of perseverance","source_sentences":[1,3],"distractors":["The value of education","The dangers of ambition","The power of friendship"],"sort_order":1000,"date_created":"2026-05-04T07:42:11.123Z"}]}}},"403":{"description":"Caller does not own the excerpt's parent list"},"404":{"description":"Excerpt not found"}},"x-requires-admin":true}},"/api/lists/reorder":{"patch":{"summary":"Reorder a list","description":"Updates the sort_order of a list for drag-drop reordering. The caller must own the list.","operationId":"reorderList","tags":["Lists"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["id","sort_order"],"properties":{"id":{"type":"string","format":"uuid","description":"The list ID to reorder"},"sort_order":{"type":"integer","minimum":0,"description":"New sort position. Compute as midpoint of neighbours: (prev + next) / 2"}}}}}},"responses":{"200":{"description":"Reorder successful","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"id":{"type":"string","format":"uuid"},"sort_order":{"type":"integer"}}}}}},"400":{"description":"Validation error"},"404":{"description":"List not found"}}}},"/api/excerpts/reorder":{"patch":{"summary":"Reorder an excerpt","description":"Updates the sort_order of an excerpt for drag-drop reordering. Admin only, must own the excerpt.","operationId":"reorderExcerpt","tags":["Excerpts"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["id","sort_order"],"properties":{"id":{"type":"string","format":"uuid","description":"The excerpt ID to reorder"},"sort_order":{"type":"integer","minimum":0,"description":"New sort position. Compute as midpoint of neighbours: (prev + next) / 2"}}}}}},"responses":{"200":{"description":"Reorder successful","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"list_id":{"type":"string","format":"uuid"},"text":{"type":"string"},"text_sentences":{"type":"array","items":{"type":"string"},"description":"Text split into sentences."},"audio_url":{"type":"string"},"image_url":{"type":"string"},"video_url":{"type":"string"},"show_excerpt":{"type":"boolean"},"is_glossary":{"type":"boolean","description":"Whether this excerpt is a glossary excerpt."},"r2_audio":{"type":"boolean","nullable":true},"sort_order":{"type":"integer"},"date_created":{"type":"string","format":"date-time"},"questions_count":{"type":"integer"}}}}}},"400":{"description":"Validation error"},"404":{"description":"Excerpt not found"}},"x-requires-admin":true}},"/api/questions/reorder":{"patch":{"summary":"Reorder a question","description":"Updates the sort_order of a question for drag-drop reordering. Admin only, must own the question.","operationId":"reorderQuestion","tags":["Questions"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["id","sort_order"],"properties":{"id":{"type":"string","format":"uuid","description":"The question ID to reorder"},"sort_order":{"type":"integer","minimum":0,"description":"New sort position. Compute as midpoint of neighbours: (prev + next) / 2"}}}}}},"responses":{"200":{"description":"Reorder successful","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"excerpt_id":{"type":"string","format":"uuid"},"question":{"type":"string"},"answer":{"type":"string"},"source_sentences":{"type":"array","items":{"type":"integer"}},"distractors":{"type":"array","items":{"type":"string"}},"is_glossary":{"type":"boolean"},"is_extended":{"type":"boolean","description":"Whether this question uses knowledge beyond the excerpt text. Read-only, set by AI generation."},"r2_audio":{"type":"boolean","nullable":true},"sort_order":{"type":"integer"},"date_created":{"type":"string","format":"date-time"}}}}}},"400":{"description":"Validation error"},"404":{"description":"Question not found"}},"x-requires-admin":true}},"/api/questions/{id}":{"patch":{"summary":"Update a question","description":"Updates one or more fields on a question. At least one field must be provided. The caller must own the question's parent chapter list. Admin only.","operationId":"updateQuestion","tags":["Questions"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The question ID to update"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","minProperties":1,"properties":{"question":{"type":"string","minLength":1,"description":"Updated question text"},"answer":{"type":"string","minLength":1,"description":"Updated answer text"},"source_sentences":{"type":"array","items":{"type":"integer","minimum":1},"description":"Updated 1-based sentence numbers the answer derives from"},"distractors":{"type":"array","items":{"type":"string"},"maxItems":7,"description":"Updated incorrect alternative answers for multiple-choice"}}}}}},"responses":{"200":{"description":"Question updated","content":{"application/json":{"schema":{"type":"object","required":["success","id","user_id","excerpt_id","question","answer","source_sentences","distractors","sort_order","date_created"],"properties":{"success":{"type":"boolean"},"id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"excerpt_id":{"type":"string","format":"uuid"},"question":{"type":"string"},"answer":{"type":"string"},"source_sentences":{"type":"array","items":{"type":"integer"}},"distractors":{"type":"array","items":{"type":"string"}},"is_glossary":{"type":"boolean","description":"Whether this question belongs to a glossary excerpt"},"is_extended":{"type":"boolean","description":"Whether this question uses knowledge beyond the excerpt text (extra credit)."},"r2_audio":{"type":"boolean","nullable":true,"description":"TTS audio state"},"sort_order":{"type":"integer"},"date_created":{"type":"string","format":"date-time"}}}}}},"400":{"description":"Validation error or no fields provided"},"403":{"description":"Caller does not own the question's parent list"},"404":{"description":"Question not found"}},"x-requires-admin":true},"delete":{"summary":"Delete a question","description":"Deletes a question by ID. The caller must own the question's parent chapter list. Admin only.","operationId":"deleteQuestion","tags":["Questions"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"},"description":"The question ID to delete"}],"responses":{"200":{"description":"Question deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"403":{"description":"Caller does not own the question's parent list"},"404":{"description":"Question not found"}},"x-requires-admin":true}},"/api/profile/weekly-stats":{"get":{"summary":"Get user's weekly activity stats","description":"Returns aggregated weekly stats for the authenticated user. Closed weeks come from pre-computed summaries; the current week is computed live from detail tables. Covers testing, sessions, API usage, learning progress, and more.","operationId":"getUserWeeklyStats","tags":["Profile"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"from_week","in":"query","schema":{"type":"integer"},"description":"Start week number (default: to_week - limit + 1)"},{"name":"to_week","in":"query","schema":{"type":"integer"},"description":"End week number (default: current week)"},{"name":"limit","in":"query","schema":{"type":"integer","default":12,"maximum":52},"description":"Max weeks to return"}],"responses":{"200":{"description":"Weekly stats array","content":{"application/json":{"schema":{"type":"object","properties":{"current_week":{"type":"integer"},"weeks":{"type":"array","items":{"$ref":"#/components/schemas/WeeklyStatsSummary"}}}}}}}}}},"/api/profile/weekly-stats/{weekNumber}":{"get":{"summary":"Get user's stats for a single week","operationId":"getUserWeeklyStatsSingle","tags":["Profile"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"weekNumber","in":"path","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"description":"Weekly stats for the requested week"},"400":{"description":"Invalid week number"},"404":{"description":"No stats for this week"}}}},"/api/admin/weekly-stats/aggregate":{"post":{"summary":"Trigger weekly stats aggregation","description":"Click POST with no params to aggregate all missing weeks. Idempotent — re-running is a safe no-op. Can take several minutes on first run if backfilling many weeks. Does NOT delete any source data — that is a separate prune step. Optionally pass ?week_number=N to aggregate one specific week.","operationId":"aggregateWeeklyStats","tags":["Admin"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"week_number","in":"query","schema":{"type":"integer"},"description":"Optional. Aggregate only this specific week instead of all missing."}],"responses":{"200":{"description":"Aggregation result","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"weeks_processed":{"type":"integer"},"rows_inserted":{"type":"integer"}}}}}}},"x-requires-admin":true}},"/api/admin/weekly-stats/status":{"get":{"summary":"Get aggregation status","description":"Read-only. Safe to call at any time, including before any aggregation has run. Returns current week number, last aggregated week (null if none), pending week count, and total rows.","operationId":"getWeeklyStatsStatus","tags":["Admin"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"responses":{"200":{"description":"Aggregation status","content":{"application/json":{"schema":{"type":"object","properties":{"current_week":{"type":"integer"},"last_aggregated_week":{"type":"integer","nullable":true},"weeks_pending":{"type":"integer"},"total_rows":{"type":"integer"}}}}}}},"x-requires-admin":true}},"/api/admin/weekly-stats/prune":{"post":{"summary":"Prune old detail table rows","description":"Click POST with no params for a safe dry run — shows what would be deleted without deleting anything. Add ?dry_run=false to actually delete. Only prunes rows 2+ weeks behind the current week that have already been aggregated. Always run aggregate first. Run VACUUM ANALYZE on pruned tables afterwards.","operationId":"pruneDetailTables","tags":["Admin"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"dry_run","in":"query","schema":{"type":"string","enum":["true","false"],"default":"true"},"description":"Optional. Defaults to true (report only). Set to false to actually delete."},{"name":"retention_weeks","in":"query","schema":{"type":"integer","minimum":2,"default":2},"description":"Optional. Weeks to retain (defaults to 2, minimum 2)."}],"responses":{"200":{"description":"Prune report","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"dry_run":{"type":"boolean"},"tables":{"type":"array","items":{"type":"object","properties":{"table":{"type":"string"},"rows_deleted":{"type":"integer"},"weeks_pruned":{"type":"integer"}}}}}}}}}},"x-requires-admin":true}},"/api/admin/weekly-stats/user/{userId}":{"get":{"summary":"Admin view of a user's weekly stats","operationId":"getAdminUserWeeklyStats","tags":["Admin"],"security":[{"bearerAuth":[]},{"cookieAuth":[]}],"parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"from_week","in":"query","schema":{"type":"integer"}},{"name":"to_week","in":"query","schema":{"type":"integer"}},{"name":"limit","in":"query","schema":{"type":"integer","default":12}}],"responses":{"200":{"description":"User's weekly stats","content":{"application/json":{"schema":{"type":"object","properties":{"current_week":{"type":"integer"},"weeks":{"type":"array","items":{"$ref":"#/components/schemas/WeeklyStatsSummary"}}}}}}}},"x-requires-admin":true}}},"servers":[{"url":"","description":"Production"}]}