{"openapi":"3.1.0","info":{"title":"HiveLearn API","version":"0.1.0","description":"The HiveLearn REST API lets you programmatically manage communities, members, courses, posts, events, quizzes, enrollments, certificates, resources, leaderboards, and learning tracks. Authenticate with an API key generated from your community settings.","contact":{"name":"HiveLearn Support","url":"https://hivelearn.app","email":"support@hivelearn.app"},"license":{"name":"Proprietary","url":"https://hivelearn.app/terms"},"x-logo":{"url":"https://hivelearn.app/hivelearn-icon.png","altText":"HiveLearn"}},"servers":[{"url":"https://api.hivelearn.app","description":"Production"}],"tags":[{"name":"Communities","description":"Community-level operations"},{"name":"Members","description":"Community membership and roles"},{"name":"Posts","description":"Community posts and discussions"},{"name":"Events","description":"Community events and RSVPs"},{"name":"Courses","description":"Courses, modules, and lessons"},{"name":"Quizzes","description":"Quizzes and quiz questions"},{"name":"Enrollments","description":"Course enrollments and student progress"},{"name":"Gradebook","description":"Course gradebook and completion analytics"},{"name":"Certificates","description":"Issued certificates and public verification"},{"name":"Resources","description":"Downloadable resource library items"},{"name":"Leaderboard","description":"Member rankings, points, and levels"},{"name":"Learning Tracks","description":"Curated course sequences and member track progress"}],"components":{"securitySchemes":{"ApiKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"API key generated from community settings. Format: hl_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}},"schemas":{"Community":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"name":{"type":"string","example":"HiveLearn Beta"},"slug":{"type":"string","example":"hivelearn-beta"},"description":{"type":["string","null"],"example":"Early-access community for HiveLearn API beta testers"},"icon_url":{"type":["string","null"],"format":"uri","example":"https://cdn.hivelearn.app/icons/hivelearn-beta.png"},"member_count":{"type":"integer","example":1250},"created_at":{"type":"string","format":"date-time"}},"required":["id","name","slug","description","icon_url","member_count","created_at"]},"Problem":{"type":"object","properties":{"type":{"type":"string","format":"uri","example":"https://hivelearn.app/errors/unauthorized"},"title":{"type":"string","example":"Invalid API key"},"status":{"type":"integer","example":401},"detail":{"type":"string","example":"The provided API key was revoked or does not exist"},"instance":{"type":"string","example":"/v1/communities/me"}},"required":["type","title","status"]},"MemberProfile":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"11111111-2222-3333-4444-555555555555"},"full_name":{"type":["string","null"],"example":"Ada Lovelace"},"display_name":{"type":["string","null"],"example":"ada"},"avatar_url":{"type":["string","null"],"format":"uri","example":"https://cdn.hivelearn.app/avatars/ada.png"}},"required":["id","full_name","display_name","avatar_url"]},"Member":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"user_id":{"type":"string","format":"uuid","example":"11111111-2222-3333-4444-555555555555"},"role":{"type":["string","null"],"enum":["owner","admin","moderator","member"],"example":"member"},"joined_at":{"type":["string","null"],"format":"date-time","example":"2026-01-15T09:30:00.000Z"},"is_banned":{"type":["boolean","null"],"example":false},"profile":{"$ref":"#/components/schemas/MemberProfile"}},"required":["id","user_id","role","joined_at","is_banned","profile"]},"Pagination":{"type":"object","properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":1250}},"required":["limit","offset","total"]},"MemberListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Member"}},"pagination":{"$ref":"#/components/schemas/Pagination"}},"required":["data","pagination"]},"UpdateMemberBody":{"type":"object","properties":{"role":{"type":"string","enum":["member","moderator","admin"],"description":"New role (owner cannot be assigned via API).","example":"moderator"},"is_banned":{"type":"boolean","example":false}}},"PostAuthor":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"11111111-2222-3333-4444-555555555555"},"full_name":{"type":["string","null"],"example":"Ada Lovelace"},"display_name":{"type":["string","null"],"example":"ada"},"avatar_url":{"type":["string","null"],"format":"uri","example":"https://cdn.hivelearn.app/avatars/ada.png"}},"required":["id","full_name","display_name","avatar_url"]},"Post":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"community_id":{"type":"string","format":"uuid","example":"bbbb2222-cccc-3333-dddd-444444444444"},"author_id":{"type":"string","format":"uuid","example":"11111111-2222-3333-4444-555555555555"},"category":{"type":["string","null"],"example":"General"},"content":{"type":["string","null"],"description":"Legacy plain-text body. May be empty when `content_json` is populated.","example":"Excited to share my progress this week!"},"content_json":{"description":"Tiptap JSON representation (when `content_format` = 'tiptap_json').","example":{"type":"doc","content":[]}},"content_format":{"type":["string","null"],"description":"Either 'markdown' (legacy) or 'tiptap_json'.","example":"tiptap_json"},"is_pinned":{"type":"boolean","example":false},"likes_count":{"type":"integer","example":12},"comments_count":{"type":"integer","example":3},"created_at":{"type":"string","format":"date-time","example":"2026-04-15T09:30:00.000Z"},"updated_at":{"type":["string","null"],"format":"date-time","example":"2026-04-16T11:05:00.000Z"},"author":{"$ref":"#/components/schemas/PostAuthor"}},"required":["id","community_id","author_id","category","content","content_format","is_pinned","likes_count","comments_count","created_at","updated_at","author"]},"PostListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Post"}},"pagination":{"allOf":[{"$ref":"#/components/schemas/Pagination"},{"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":842}}}]}},"required":["data","pagination"]},"CreatePostBody":{"type":"object","properties":{"content":{"type":"string","maxLength":20000,"description":"Legacy plain-text body. Optional when `content_json` is provided.","example":"Excited to share my progress this week at HiveLearn Beta!"},"content_json":{"description":"Tiptap JSON body. Optional when `content` is provided.","example":{"type":"doc","content":[]}},"content_format":{"type":"string","enum":["markdown","tiptap_json"],"default":"markdown","description":"Either 'markdown' (legacy) or 'tiptap_json'. Defaults to 'markdown'.","example":"markdown"},"category":{"type":"string","minLength":1,"maxLength":100,"example":"General"}}},"UpdatePostBody":{"type":"object","properties":{"content":{"type":"string","maxLength":20000},"content_json":{},"content_format":{"type":"string","enum":["markdown","tiptap_json"]},"category":{"type":"string","minLength":1,"maxLength":100},"is_pinned":{"type":"boolean"}}},"EventHost":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"11111111-2222-3333-4444-555555555555"},"full_name":{"type":["string","null"],"example":"Ada Lovelace"},"display_name":{"type":["string","null"],"example":"ada"},"avatar_url":{"type":["string","null"],"format":"uri","example":"https://cdn.hivelearn.app/avatars/ada.png"}},"required":["id","full_name","display_name","avatar_url"]},"Event":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"community_id":{"type":"string","format":"uuid","example":"bbbb2222-cccc-3333-dddd-444444444444"},"host_id":{"type":"string","format":"uuid","example":"11111111-2222-3333-4444-555555555555"},"title":{"type":"string","example":"Weekly Founders Roundtable"},"description":{"type":["string","null"],"description":"Legacy plain-text body. May be empty when `description_json` is populated.","example":"Join us for our weekly founder discussion."},"description_json":{"description":"Tiptap JSON representation (when `description_format` = 'tiptap_json').","example":{"type":"doc","content":[]}},"description_format":{"type":["string","null"],"description":"Either 'markdown' (legacy) or 'tiptap_json'.","example":"tiptap_json"},"event_type":{"type":["string","null"],"description":"One of 'virtual', 'in-person', 'hybrid'.","example":"virtual"},"location":{"type":["string","null"],"description":"Physical address (in-person / hybrid) or free-form location text.","example":"123 Main St, San Francisco, CA"},"meeting_url":{"type":["string","null"],"description":"Join URL for virtual / hybrid events.","example":"https://zoom.us/j/123456789"},"start_date":{"type":"string","format":"date-time","example":"2026-05-01T18:00:00.000Z"},"end_date":{"type":"string","format":"date-time","example":"2026-05-01T19:30:00.000Z"},"timezone":{"type":["string","null"],"example":"America/Los_Angeles"},"max_attendees":{"type":["integer","null"],"description":"Capacity cap. `null` means unlimited.","example":100},"current_attendees":{"type":"integer","description":"Count of attendees with rsvp_status = 'going'.","example":42},"tags":{"type":"array","items":{"type":"string"},"example":["networking","founders"]},"cover_image_url":{"type":["string","null"],"format":"uri","example":"https://cdn.hivelearn.app/events/cover.png"},"is_cancelled":{"type":"boolean","example":false},"created_at":{"type":"string","format":"date-time","example":"2026-04-15T09:30:00.000Z"},"updated_at":{"type":["string","null"],"format":"date-time","example":"2026-04-16T11:05:00.000Z"},"host":{"$ref":"#/components/schemas/EventHost"}},"required":["id","community_id","host_id","title","description","description_format","event_type","location","meeting_url","start_date","end_date","timezone","max_attendees","current_attendees","tags","cover_image_url","is_cancelled","created_at","updated_at","host"]},"EventListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Event"}},"pagination":{"allOf":[{"$ref":"#/components/schemas/Pagination"},{"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":128}}}]}},"required":["data","pagination"]},"CreateEventBody":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":200,"example":"HiveLearn Beta Launch Party"},"description":{"type":"string","maxLength":20000,"description":"Legacy plain-text description.","example":"Join us to celebrate the HiveLearn Beta launch with Ada Lovelace."},"description_json":{"description":"Tiptap JSON description.","example":{"type":"doc","content":[]}},"description_format":{"type":"string","enum":["markdown","tiptap_json"],"default":"markdown","example":"markdown"},"event_type":{"type":"string","enum":["virtual","in-person","hybrid"],"example":"virtual"},"location":{"type":"string","maxLength":500,"example":"123 Main St, San Francisco, CA"},"meeting_url":{"type":"string","format":"uri","example":"https://zoom.us/j/123456789"},"start_date":{"type":"string","format":"date-time","example":"2026-05-01T18:00:00.000Z"},"end_date":{"type":"string","format":"date-time","example":"2026-05-01T19:30:00.000Z"},"timezone":{"type":"string","maxLength":100,"example":"America/Los_Angeles"},"max_attendees":{"type":"integer","minimum":1,"example":100},"tags":{"type":"array","items":{"type":"string","minLength":1,"maxLength":50},"default":[],"example":["networking","launch"]},"cover_image_url":{"type":"string","format":"uri","example":"https://cdn.hivelearn.app/events/cover.png"}},"required":["title","event_type","start_date","end_date"]},"UpdateEventBody":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":200},"description":{"type":"string","maxLength":20000},"description_json":{},"description_format":{"type":"string","enum":["markdown","tiptap_json"]},"event_type":{"type":"string","enum":["virtual","in-person","hybrid"]},"location":{"type":"string","maxLength":500},"meeting_url":{"type":"string","format":"uri"},"start_date":{"type":"string","format":"date-time"},"end_date":{"type":"string","format":"date-time"},"timezone":{"type":"string","maxLength":100},"max_attendees":{"type":["integer","null"],"minimum":1},"tags":{"type":"array","items":{"type":"string","minLength":1,"maxLength":50}},"cover_image_url":{"type":"string","format":"uri"},"is_cancelled":{"type":"boolean","description":"Toggle cancellation. Set to `true` to cancel the event while keeping the row visible to readers (distinct from DELETE, which removes it)."}}},"CourseAuthor":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"11111111-2222-3333-4444-555555555555"},"full_name":{"type":["string","null"],"example":"Ada Lovelace"},"display_name":{"type":["string","null"],"example":"ada"},"avatar_url":{"type":["string","null"],"format":"uri","example":"https://cdn.hivelearn.app/avatars/ada.png"}},"required":["id","full_name","display_name","avatar_url"]},"Course":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"community_id":{"type":"string","format":"uuid","example":"bbbb2222-cccc-3333-dddd-444444444444"},"author_id":{"type":"string","format":"uuid","example":"11111111-2222-3333-4444-555555555555"},"title":{"type":"string","example":"From Zero to Launch"},"description":{"type":["string","null"],"description":"Legacy plain-text body. May be empty when `description_json` is populated.","example":"A hands-on course for first-time founders."},"description_json":{"description":"Tiptap JSON representation (when `description_format` = 'tiptap_json').","example":{"type":"doc","content":[]}},"description_format":{"type":["string","null"],"description":"Either 'markdown' (legacy) or 'tiptap_json'.","example":"tiptap_json"},"thumbnail_url":{"type":["string","null"],"format":"uri","example":"https://cdn.hivelearn.app/courses/cover.png"},"visibility":{"type":"string","description":"Who can see the course. One of 'all_members' or 'admin_only'.","example":"all_members"},"instructor_name":{"type":["string","null"],"example":"Ada Lovelace"},"instructor_avatar_url":{"type":["string","null"],"format":"uri","example":"https://cdn.hivelearn.app/avatars/ada.png"},"tags":{"type":"array","items":{"type":"string"},"example":["founders","intro"]},"difficulty":{"type":["string","null"],"description":"One of 'beginner', 'intermediate', 'advanced' (or null).","example":"beginner"},"lessons_count":{"type":"integer","example":12},"enrollment_count":{"type":"integer","example":345},"is_published":{"type":"boolean","example":true},"is_featured":{"type":"boolean","example":false},"is_pinned":{"type":"boolean","example":false},"created_at":{"type":"string","format":"date-time","example":"2026-04-15T09:30:00.000Z"},"updated_at":{"type":["string","null"],"format":"date-time","example":"2026-04-16T11:05:00.000Z"},"author":{"$ref":"#/components/schemas/CourseAuthor"}},"required":["id","community_id","author_id","title","description","description_format","thumbnail_url","visibility","instructor_name","instructor_avatar_url","tags","difficulty","lessons_count","enrollment_count","is_published","is_featured","is_pinned","created_at","updated_at","author"]},"CourseListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Course"}},"pagination":{"allOf":[{"$ref":"#/components/schemas/Pagination"},{"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":84}}}]}},"required":["data","pagination"]},"Module":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"cccc3333-dddd-4444-eeee-555555555555"},"course_id":{"type":"string","format":"uuid","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"title":{"type":"string","example":"Week 1 — Foundations"},"sort_order":{"type":"integer","description":"Zero-based index within the course. Modules are returned sorted ascending by this value.","example":0},"created_at":{"type":"string","format":"date-time","example":"2026-04-15T09:30:00.000Z"}},"required":["id","course_id","title","sort_order","created_at"]},"ModuleListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Module"}}},"required":["data"]},"Lesson":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"dddd4444-eeee-5555-ffff-666666666666"},"course_id":{"type":"string","format":"uuid","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"community_id":{"type":"string","format":"uuid","example":"bbbb2222-cccc-3333-dddd-444444444444"},"module_id":{"type":["string","null"],"format":"uuid","description":"UUID of the owning module (internally `section_id` in the DB). `null` means the lesson is unsorted/staged and not yet placed in a module.","example":"cccc3333-dddd-4444-eeee-555555555555"},"title":{"type":"string","example":"Introduction"},"description":{"type":["string","null"],"description":"Legacy plain-text lesson description.","example":"Kick-off video and setup instructions."},"content_url":{"type":["string","null"],"description":"Primary media URL for the lesson (YouTube link, video file, PDF, etc.).","example":"https://www.youtube.com/watch?v=abc123"},"content_type":{"type":["string","null"],"description":"Media kind for `content_url`. One of 'youtube', 'google_drive', 'video', 'image', 'document', 'pitch'.","example":"youtube"},"content_json":{"description":"Tiptap JSON body for text-based lessons (when `content_format` = 'tiptap_json').","example":{"type":"doc","content":[]}},"content_format":{"type":["string","null"],"description":"Either 'markdown' (legacy) or 'tiptap_json'.","example":"tiptap_json"},"thumbnail_url":{"type":["string","null"],"format":"uri","example":"https://cdn.hivelearn.app/lessons/thumb.png"},"duration_seconds":{"type":"integer","description":"Length of the lesson media in seconds. 0 when unknown.","example":900},"sort_order":{"type":"integer","description":"Zero-based index within the owning module (or within the staging bucket when `module_id` is null).","example":0},"is_published":{"type":"boolean","example":true},"is_preview":{"type":"boolean","description":"When true, non-enrolled users can view this lesson as a free preview.","example":false},"created_at":{"type":"string","format":"date-time","example":"2026-04-15T09:30:00.000Z"},"updated_at":{"type":["string","null"],"format":"date-time","example":"2026-04-16T11:05:00.000Z"}},"required":["id","course_id","community_id","module_id","title","description","content_url","content_type","content_format","thumbnail_url","duration_seconds","sort_order","is_published","is_preview","created_at","updated_at"]},"LessonListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Lesson"}}},"required":["data"]},"CreateCourseInput":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":200,"description":"Required. 1–200 characters.","example":"From Zero to Launch"},"description":{"type":"string","maxLength":20000,"description":"Legacy plain-text body. Optional when `description_json` is provided.","example":"A hands-on course for first-time founders."},"description_json":{"description":"Tiptap JSON body. Optional — pair with `description_format: 'tiptap_json'`.","example":{"type":"doc","content":[]}},"description_format":{"type":"string","enum":["markdown","tiptap_json"],"description":"Either 'markdown' (legacy) or 'tiptap_json'.","example":"tiptap_json"},"thumbnail_url":{"type":["string","null"],"format":"uri","example":"https://cdn.hivelearn.app/courses/cover.png"},"visibility":{"type":"string","enum":["all_members","admin_only"],"default":"all_members","description":"Who can see the course. Defaults to 'all_members'.","example":"all_members"},"instructor_name":{"type":["string","null"],"maxLength":200,"example":"Ada Lovelace"},"instructor_avatar_url":{"type":["string","null"],"format":"uri","example":"https://cdn.hivelearn.app/avatars/ada.png"},"tags":{"type":"array","items":{"type":"string","minLength":1,"maxLength":50},"default":[],"example":["founders","intro"]},"difficulty":{"type":["string","null"],"enum":["beginner","intermediate","advanced"],"example":"beginner"}},"required":["title"]},"UpdateCourseInput":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":200},"description":{"type":["string","null"],"maxLength":20000},"description_json":{},"description_format":{"type":"string","enum":["markdown","tiptap_json"]},"thumbnail_url":{"type":["string","null"],"format":"uri"},"visibility":{"type":"string","enum":["all_members","admin_only"]},"instructor_name":{"type":["string","null"],"maxLength":200},"instructor_avatar_url":{"type":["string","null"],"format":"uri"},"tags":{"type":"array","items":{"type":"string","minLength":1,"maxLength":50}},"difficulty":{"type":["string","null"],"enum":["beginner","intermediate","advanced"]},"is_published":{"type":"boolean"},"is_featured":{"type":"boolean"},"is_pinned":{"type":"boolean"}}},"CreateModuleInput":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":200,"description":"Required. 1–200 characters.","example":"Week 1 — Foundations"},"sort_order":{"type":"integer","minimum":0,"description":"Optional. Zero-based index. When omitted the server assigns the next available position (max + 1).","example":0}},"required":["title"]},"UpdateModuleInput":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":200},"sort_order":{"type":"integer","minimum":0}}},"CreateLessonInput":{"type":"object","properties":{"module_id":{"type":["string","null"],"format":"uuid","description":"Owning module UUID. Pass `null` or omit to stage the lesson (unsorted).","example":"cccc3333-dddd-4444-eeee-555555555555"},"title":{"type":"string","minLength":1,"maxLength":200,"description":"Required. 1–200 characters.","example":"Introduction"},"description":{"type":["string","null"],"maxLength":20000,"example":"Kick-off video and setup instructions."},"content_url":{"type":"string","format":"uri","description":"Required. Primary media URL for the lesson (YouTube link, video file, PDF, etc.).","example":"https://www.youtube.com/watch?v=abc123"},"content_type":{"type":"string","enum":["youtube","google_drive","video","image","document","pitch"],"default":"youtube","description":"Media kind for `content_url`. One of 'youtube', 'google_drive', 'video', 'image', 'document', 'pitch'.","example":"youtube"},"content_json":{"description":"Tiptap JSON body for text-based lessons.","example":{"type":"doc","content":[]}},"content_format":{"type":"string","enum":["markdown","tiptap_json"],"example":"tiptap_json"},"thumbnail_url":{"type":["string","null"],"format":"uri"},"duration_seconds":{"type":"integer","minimum":0,"default":0,"description":"Lesson media length in seconds. Defaults to 0.","example":900},"sort_order":{"type":"integer","minimum":0,"description":"Optional. Zero-based index within the owning module (or staging bucket when `module_id` is null). When omitted the server assigns the next available position.","example":0},"is_published":{"type":"boolean","default":false,"description":"Defaults to false (draft).","example":false},"is_preview":{"type":"boolean","default":false,"description":"When true, non-enrolled users can view this lesson as a free preview.","example":false}},"required":["title","content_url"]},"UpdateLessonInput":{"type":"object","properties":{"module_id":{"type":["string","null"],"format":"uuid"},"title":{"type":"string","minLength":1,"maxLength":200},"description":{"type":["string","null"],"maxLength":20000},"content_url":{"type":"string","format":"uri"},"content_type":{"type":"string","enum":["youtube","google_drive","video","image","document","pitch"]},"content_json":{},"content_format":{"type":"string","enum":["markdown","tiptap_json"]},"thumbnail_url":{"type":["string","null"],"format":"uri"},"duration_seconds":{"type":"integer","minimum":0},"sort_order":{"type":"integer","minimum":0},"is_published":{"type":"boolean"},"is_preview":{"type":"boolean"}}},"OutlineResponseLesson":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"title":{"type":"string"},"sort_order":{"type":"integer"}},"required":["id","title","sort_order"]},"OutlineResponseModule":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"title":{"type":"string"},"sort_order":{"type":"integer"},"lessons":{"type":"array","items":{"$ref":"#/components/schemas/OutlineResponseLesson"}}},"required":["id","title","sort_order","lessons"]},"CourseOutlineResponse":{"type":"object","properties":{"course":{"$ref":"#/components/schemas/Course"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/OutlineResponseModule"}}},"required":["course","modules"]},"OutlineLesson":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":200,"example":"Introduction"},"description":{"type":"string","maxLength":20000,"example":"What you'll learn in this lesson."},"content_url":{"type":"string","format":"uri","description":"Optional at outline time. When omitted we store an empty string placeholder so the lesson can be filled in later via PATCH /v1/lessons/{id}/content.","example":"https://www.youtube.com/watch?v=abc123"},"content_type":{"type":"string","enum":["youtube","google_drive","video","image","document","pitch"],"example":"youtube"}},"required":["title"]},"OutlineModule":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":200,"example":"Week 1 — Foundations"},"lessons":{"type":"array","items":{"$ref":"#/components/schemas/OutlineLesson"},"maxItems":100,"default":[],"description":"Lessons inside this module. Optional — may be empty to create an empty module."}},"required":["title"]},"CreateCourseOutlineInput":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":200,"example":"From Zero to Launch"},"description":{"type":"string","maxLength":20000},"description_json":{},"description_format":{"type":"string","enum":["markdown","tiptap_json"]},"thumbnail_url":{"type":["string","null"],"format":"uri"},"visibility":{"type":"string","enum":["all_members","admin_only"],"default":"all_members"},"instructor_name":{"type":["string","null"],"maxLength":200},"instructor_avatar_url":{"type":["string","null"],"format":"uri"},"tags":{"type":"array","items":{"type":"string","minLength":1,"maxLength":50},"default":[]},"difficulty":{"type":["string","null"],"enum":["beginner","intermediate","advanced"]},"modules":{"type":"array","items":{"$ref":"#/components/schemas/OutlineModule"},"minItems":1,"maxItems":50,"description":"Ordered list of modules. sort_order is assigned by position in the array (0-indexed)."}},"required":["title","modules"]},"UpdateLessonContentInput":{"type":"object","properties":{"content_url":{"type":"string","format":"uri"},"content_type":{"type":"string","enum":["youtube","google_drive","video","image","document","pitch"]},"content_json":{},"content_format":{"type":"string","enum":["markdown","tiptap_json"]},"description":{"type":["string","null"],"maxLength":20000},"duration_seconds":{"type":"integer","minimum":0},"thumbnail_url":{"type":["string","null"],"format":"uri"}}},"ReorderLessonsResponse":{"type":"object","properties":{"reordered":{"type":"integer","example":5}},"required":["reordered"]},"ReorderLessonsInput":{"type":"object","properties":{"lesson_ids":{"type":"array","items":{"type":"string","format":"uuid"},"minItems":1,"maxItems":200,"description":"Full ordered list of lesson UUIDs. All must already belong to the target module. sort_order is assigned by array index (0-based)."}},"required":["lesson_ids"]},"StructureLesson":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"title":{"type":"string"},"module_id":{"type":["string","null"],"format":"uuid"},"content_type":{"type":["string","null"]},"sort_order":{"type":"integer"},"is_published":{"type":"boolean"}},"required":["id","title","module_id","content_type","sort_order","is_published"]},"StructureModule":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"title":{"type":"string"},"sort_order":{"type":"integer"},"lessons":{"type":"array","items":{"$ref":"#/components/schemas/StructureLesson"}}},"required":["id","title","sort_order","lessons"]},"CourseStructureResponse":{"type":"object","properties":{"course":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"title":{"type":"string"},"is_published":{"type":"boolean"}},"required":["id","title","is_published"]},"modules":{"type":"array","items":{"$ref":"#/components/schemas/StructureModule"}},"unsorted_lessons":{"type":"array","items":{"$ref":"#/components/schemas/StructureLesson"},"description":"Lessons whose module_id is null (staged, not yet placed in a module)."}},"required":["course","modules","unsorted_lessons"]},"Quiz":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"77777777-aaaa-bbbb-cccc-111111111111"},"community_id":{"type":"string","format":"uuid","example":"bbbb2222-cccc-3333-dddd-444444444444"},"course_id":{"type":["string","null"],"format":"uuid","description":"The course this quiz is attached to, if any. Quizzes are always attached to a lesson and/or a course — both foreign keys are nullable independently but in practice at least one is populated.","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"lesson_id":{"type":["string","null"],"format":"uuid","description":"The lesson this quiz is attached to, if any. A `lesson_id`-scoped quiz serves as a per-lesson knowledge check; a `course_id`-only quiz is a final exam.","example":"dddd4444-eeee-5555-ffff-666666666666"},"author_id":{"type":"string","format":"uuid","example":"11111111-2222-3333-4444-555555555555"},"title":{"type":"string","example":"Module 1 Knowledge Check"},"description":{"type":["string","null"],"description":"Quiz-card subtitle shown before the learner starts an attempt.","example":"A short quiz covering the first three lessons."},"instructions":{"type":["string","null"],"description":"Pre-attempt instructions shown on the quiz landing screen.","example":"You have one attempt. A passing score of 70% is required to continue."},"passing_score":{"type":"integer","description":"Integer percentage (0–100) that learners must meet or exceed to pass. Scored server-side via the `calculate_quiz_score` RPC.","example":70},"max_attempts":{"type":["integer","null"],"description":"Maximum number of completed attempts a learner is allowed. `null` means unlimited.","example":3},"time_limit_seconds":{"type":["integer","null"],"description":"Time limit per attempt in SECONDS (not minutes). `null` means no time limit. The column is stored in seconds in the DB; client UIs convert on display.","example":600},"show_correct_answers":{"type":"boolean","description":"Whether correct answers are revealed to the learner AFTER they submit. Does not affect what this public API returns — correct-answer indicators are stripped from question responses regardless of this setting.","example":true},"randomize_questions":{"type":"boolean","description":"When true, clients shuffle question order per attempt.","example":false},"is_required":{"type":"boolean","description":"Whether passing this quiz is required to complete the parent lesson or course.","example":true},"is_published":{"type":"boolean","example":true},"created_at":{"type":"string","format":"date-time","example":"2026-04-15T09:30:00.000Z"},"updated_at":{"type":["string","null"],"format":"date-time","example":"2026-04-16T11:05:00.000Z"}},"required":["id","community_id","course_id","lesson_id","author_id","title","description","instructions","passing_score","max_attempts","time_limit_seconds","show_correct_answers","randomize_questions","is_required","is_published","created_at","updated_at"]},"QuizListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Quiz"}},"pagination":{"allOf":[{"$ref":"#/components/schemas/Pagination"},{"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":17}}}]}},"required":["data","pagination"]},"QuizQuestionOption":{"type":"object","properties":{"id":{"type":"string","description":"Stable option identifier used when submitting an attempt. Typically a short token like 'a', 'b', 'true', 'false'.","example":"a"},"text":{"type":"string","example":"Ada Lovelace"}},"required":["id","text"]},"QuizQuestion":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"99999999-aaaa-bbbb-cccc-222222222222"},"quiz_id":{"type":"string","format":"uuid","example":"77777777-aaaa-bbbb-cccc-111111111111"},"question_text":{"type":"string","example":"Who is generally credited with writing the first computer algorithm?"},"question_type":{"type":"string","description":"One of 'multiple_choice' or 'true_false'. Enforced by a CHECK constraint on the `quiz_questions.question_type` column.","example":"multiple_choice"},"options":{"type":"array","items":{"$ref":"#/components/schemas/QuizQuestionOption"},"description":"Answerable options for the question. The `is_correct` indicator is INTENTIONALLY stripped from this payload to preserve quiz integrity — the public API never reveals which option is the correct answer, regardless of the quiz's `show_correct_answers` setting. Likewise, the `explanation` column on the question row (which often paraphrases the correct answer) is not exposed."},"points":{"type":"integer","description":"Point value awarded for a correct answer on this question.","example":1},"sort_order":{"type":"integer","description":"Zero-based index within the quiz. Questions are returned ordered by this field ascending so client apps see the author's intended sequence.","example":0},"created_at":{"type":"string","format":"date-time","example":"2026-04-15T09:30:00.000Z"},"updated_at":{"type":["string","null"],"format":"date-time","example":"2026-04-16T11:05:00.000Z"}},"required":["id","quiz_id","question_text","question_type","options","points","sort_order","created_at","updated_at"]},"QuizQuestionListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/QuizQuestion"}}},"required":["data"]},"CreateQuizBody":{"type":"object","properties":{"lesson_id":{"type":"string","format":"uuid","description":"The lesson this quiz attaches to. Required — the write API only supports lesson-scoped quizzes. The lesson's community is used to scope authorization, and its parent course is auto-populated on the quiz row.","example":"dddd4444-eeee-5555-ffff-666666666666"},"title":{"type":"string","minLength":1,"maxLength":500,"example":"Module 1 Knowledge Check"},"description":{"type":["string","null"],"maxLength":5000,"example":"A short quiz covering the first three lessons."},"passing_score":{"type":"integer","minimum":0,"maximum":100,"default":70,"description":"Integer percentage (0–100) required to pass. Defaults to 70.","example":70},"time_limit_seconds":{"type":["integer","null"],"exclusiveMinimum":0,"description":"Time limit per attempt in SECONDS. `null` (or omitted) means no time limit.","example":600},"max_attempts":{"type":["integer","null"],"exclusiveMinimum":0,"description":"Max completed attempts. `null` (or omitted) means unlimited.","example":3},"shuffle_questions":{"type":"boolean","default":false,"description":"When true, clients shuffle question order per attempt. Persisted as `randomize_questions` on the quiz row.","example":false}},"required":["lesson_id","title"]},"UpdateQuizBody":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":500},"description":{"type":["string","null"],"maxLength":5000},"passing_score":{"type":"integer","minimum":0,"maximum":100},"time_limit_seconds":{"type":["integer","null"],"exclusiveMinimum":0},"max_attempts":{"type":["integer","null"],"exclusiveMinimum":0},"shuffle_questions":{"type":"boolean"}},"additionalProperties":false},"QuizQuestionOptionInput":{"type":"object","properties":{"id":{"type":"string","minLength":1,"maxLength":64,"example":"a"},"text":{"type":"string","minLength":1,"maxLength":1000,"example":"Ada Lovelace"},"is_correct":{"type":"boolean","example":true}},"required":["id","text","is_correct"]},"CreateQuizQuestionBody":{"type":"object","properties":{"question_text":{"type":"string","minLength":1,"maxLength":5000,"example":"Who is generally credited with writing the first computer algorithm?"},"question_type":{"type":"string","enum":["multiple_choice","true_false"],"description":"Enforced by the DB CHECK constraint — only 'multiple_choice' and 'true_false' are accepted. Fill-in-blank / short-answer live on a different surface.","example":"multiple_choice"},"options":{"type":"array","items":{"$ref":"#/components/schemas/QuizQuestionOptionInput"},"minItems":2,"maxItems":12,"description":"Answer options. REQUIRED for `multiple_choice` (at least one must have `is_correct: true`). Ignored for `true_false` — the server synthesizes the two-option shape from `correct_answer`."},"correct_answer":{"type":"boolean","description":"The correct value for a `true_false` question. REQUIRED when `question_type = 'true_false'`. Ignored for `multiple_choice`.","example":true},"points":{"type":"integer","exclusiveMinimum":0,"default":1,"example":1},"explanation":{"type":["string","null"],"maxLength":5000,"description":"Optional teacher-facing explanation. NEVER exposed on responses from this API.","example":"Ada Lovelace's notes on the Analytical Engine included the first algorithm."},"sort_order":{"type":"integer","minimum":0,"description":"Zero-based index within the quiz. When omitted, the server appends the question (`MAX(sort_order) + 1`).","example":0}},"required":["question_text","question_type"]},"UpdateQuizQuestionBody":{"type":"object","properties":{"question_text":{"type":"string","minLength":1,"maxLength":5000},"question_type":{"type":"string","enum":["multiple_choice","true_false"],"description":"Enforced by the DB CHECK constraint — only 'multiple_choice' and 'true_false' are accepted. Fill-in-blank / short-answer live on a different surface.","example":"multiple_choice"},"options":{"type":"array","items":{"$ref":"#/components/schemas/QuizQuestionOptionInput"},"minItems":2,"maxItems":12},"correct_answer":{"type":"boolean"},"points":{"type":"integer","exclusiveMinimum":0},"explanation":{"type":["string","null"],"maxLength":5000},"sort_order":{"type":"integer","minimum":0}},"additionalProperties":false},"StudentProfile":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"11111111-2222-3333-4444-555555555555"},"full_name":{"type":["string","null"],"example":"Ada Lovelace"},"display_name":{"type":["string","null"],"example":"ada"},"avatar_url":{"type":["string","null"],"format":"uri","example":"https://cdn.hivelearn.app/avatars/ada.png"}},"required":["id","full_name","display_name","avatar_url"]},"Enrollment":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"eeee5555-ffff-6666-aaaa-777777777777"},"community_id":{"type":"string","format":"uuid","example":"bbbb2222-cccc-3333-dddd-444444444444"},"course_id":{"type":"string","format":"uuid","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"user_id":{"type":"string","format":"uuid","example":"11111111-2222-3333-4444-555555555555"},"status":{"type":"string","enum":["not_started","in_progress","completed"],"description":"Derived status. `course_enrollments` has no `status` column — this field is computed: 'completed' when `completed_at` is set, 'in_progress' when `progress_percentage > 0`, otherwise 'not_started'.","example":"in_progress"},"progress_percentage":{"type":"integer","description":"Integer percentage (0–100) of published lessons the student has completed. Kept in sync server-side by the `update_enrollment_progress` trigger in 20260110_1400_course_enrollments.sql.","example":42},"completed_lessons":{"type":"integer","description":"Count of published lessons the student has completed in this course.","example":5},"total_lessons":{"type":"integer","description":"Snapshot of the published lesson count at last recompute. Updated by the same trigger that updates `progress_percentage`.","example":12},"total_time_spent_seconds":{"type":"integer","description":"Aggregate seconds spent in lessons for this enrollment.","example":7200},"enrolled_at":{"type":"string","format":"date-time","example":"2026-01-15T09:30:00.000Z"},"started_at":{"type":["string","null"],"format":"date-time","description":"Timestamp of the student's first lesson completion. Null until they begin.","example":"2026-01-15T09:31:00.000Z"},"completed_at":{"type":["string","null"],"format":"date-time","description":"Timestamp when all published lessons were completed. Null while in progress.","example":"2026-03-02T18:00:00.000Z"},"last_accessed_at":{"type":["string","null"],"format":"date-time","example":"2026-04-18T14:22:00.000Z"},"is_eligible_for_certificate":{"type":"boolean","example":false},"certificate_issued_at":{"type":["string","null"],"format":"date-time","example":null},"created_at":{"type":"string","format":"date-time","example":"2026-01-15T09:30:00.000Z"},"updated_at":{"type":["string","null"],"format":"date-time","example":"2026-04-18T14:22:00.000Z"},"student":{"$ref":"#/components/schemas/StudentProfile"}},"required":["id","community_id","course_id","user_id","status","progress_percentage","completed_lessons","total_lessons","total_time_spent_seconds","enrolled_at","started_at","completed_at","last_accessed_at","is_eligible_for_certificate","certificate_issued_at","created_at","updated_at","student"]},"EnrollmentListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Enrollment"}},"pagination":{"allOf":[{"$ref":"#/components/schemas/Pagination"},{"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":145}}}]}},"required":["data","pagination"]},"LessonProgress":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"pppp8888-qqqq-9999-rrrr-000000000000"},"community_id":{"type":"string","format":"uuid","example":"bbbb2222-cccc-3333-dddd-444444444444"},"course_id":{"type":"string","format":"uuid","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"lesson_id":{"type":"string","format":"uuid","example":"dddd4444-eeee-5555-ffff-666666666666"},"user_id":{"type":"string","format":"uuid","example":"11111111-2222-3333-4444-555555555555"},"is_completed":{"type":"boolean","enum":[true],"description":"Always `true`. The underlying `course_progress` table only stores completions — a lesson the student hasn't touched has no row here at all. Consumers that need a per-lesson 'not started' signal should fetch the full lesson list via `GET /v1/courses/{courseId}/lessons` and subtract this set.","example":true},"completed_at":{"type":"string","format":"date-time","description":"Canonical completion timestamp (same value as `created_at`).","example":"2026-03-01T12:00:00.000Z"},"created_at":{"type":"string","format":"date-time","description":"Mirror of `completed_at` — `course_progress` has no separate row-creation timestamp.","example":"2026-03-01T12:00:00.000Z"},"progress_percent":{"type":"number","enum":[100],"description":"Always `100`. The `course_progress` table does not track partial progress — a row exists only once the lesson is complete.","example":100},"student":{"$ref":"#/components/schemas/StudentProfile"}},"required":["id","community_id","course_id","lesson_id","user_id","is_completed","completed_at","created_at","progress_percent","student"]},"LessonProgressListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/LessonProgress"}},"pagination":{"allOf":[{"$ref":"#/components/schemas/Pagination"},{"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":145}}}]}},"required":["data","pagination"]},"GradebookEntry":{"type":"object","properties":{"user_id":{"type":"string","format":"uuid","example":"11111111-2222-3333-4444-555555555555"},"student":{"$ref":"#/components/schemas/StudentProfile"},"enrollment_status":{"type":"string","enum":["not_started","in_progress","completed"],"description":"Derived status. `course_enrollments` has no `status` column — this field is computed: 'completed' when `completed_at` is set, 'in_progress' when `progress_percentage > 0`, otherwise 'not_started'.","example":"in_progress"},"enrolled_at":{"type":"string","format":"date-time","example":"2026-01-15T09:30:00.000Z"},"last_activity_at":{"type":["string","null"],"format":"date-time","description":"Most recent signal of learner activity. Computed as MAX(enrollment.last_accessed_at, course_progress.completed_at, quiz_attempts.completed_at) across the student's touches on this course. Null if the student never started.","example":"2026-04-18T14:22:00.000Z"},"quiz_attempts_count":{"type":"integer","description":"Number of COMPLETED quiz attempts by this student on quizzes attached to this course. In-progress (not yet submitted) attempts are excluded — an attempt counts once it has a non-null `completed_at`.","example":3},"avg_quiz_score":{"type":["number","null"],"description":"Arithmetic mean of the `score` field (integer percentage 0–100) across this student's COMPLETED quiz attempts on this course, rounded to 2 decimals. `null` when the student has zero completed attempts.","example":87.5},"lessons_completed":{"type":"integer","description":"Snapshot from the enrollment row (`completed_lessons`). Maintained by the `update_enrollment_progress` trigger — will match the count derivable from `course_progress` under normal conditions.","example":5},"total_lessons":{"type":"integer","description":"Snapshot from the enrollment row (`total_lessons`). Reflects the published-lesson count at last recompute; if lessons are added/removed after this student enrolled the value refreshes the next time their progress updates.","example":12}},"required":["user_id","student","enrollment_status","enrolled_at","last_activity_at","quiz_attempts_count","avg_quiz_score","lessons_completed","total_lessons"]},"GradebookListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/GradebookEntry"}},"pagination":{"allOf":[{"$ref":"#/components/schemas/Pagination"},{"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":145}}}]}},"required":["data","pagination"]},"CreateEnrollmentBody":{"type":"object","properties":{"user_id":{"type":"string","format":"uuid","description":"The student (profile id) to enroll. Must be a member of the community that owns the API key — otherwise a 404 is returned.","example":"11111111-2222-3333-4444-555555555555"},"course_id":{"type":"string","format":"uuid","description":"The course to enroll the student into. Must belong to the community that owns the API key — otherwise a 404 is returned.","example":"aaaa1111-bbbb-2222-cccc-333333333333"}},"required":["user_id","course_id"]},"CertificateUser":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"11111111-2222-3333-4444-555555555555"},"full_name":{"type":["string","null"],"example":"Ada Lovelace"},"display_name":{"type":["string","null"],"example":"ada"},"avatar_url":{"type":["string","null"],"format":"uri","example":"https://cdn.hivelearn.app/avatars/ada.png"}},"required":["id","full_name","display_name","avatar_url"]},"CertificateCourse":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"title":{"type":"string","example":"From Zero to Launch"}},"required":["id","title"]},"Certificate":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"cccc3333-dddd-4444-eeee-555555555555"},"community_id":{"type":"string","format":"uuid","example":"bbbb2222-cccc-3333-dddd-444444444444"},"course_id":{"type":"string","format":"uuid","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"user_id":{"type":"string","format":"uuid","example":"11111111-2222-3333-4444-555555555555"},"certificate_code":{"type":"string","description":"Short human-readable identifier used for public verification URLs. Unique across the entire HiveLearn platform. Format: `CERT-XXXXXX-YYYY`.","example":"CERT-A1B2C3-2026"},"status":{"type":"string","enum":["active","revoked","expired"],"description":"Lifecycle status of the certificate. `active` means honourable and not revoked; `revoked` means an admin pulled it back; `expired` means `expires_at` has passed. The `/v1/certificates/verify/{code}` endpoint collapses this plus the expiry timestamp into a single `valid` boolean.","example":"active"},"recipient_name":{"type":"string","description":"Name as it appears on the issued certificate — snapshot taken at issue time, so a profile rename after the fact does NOT retroactively alter the certificate.","example":"Ada Lovelace"},"course_title":{"type":"string","description":"Course title snapshot at issue time (same rationale as `recipient_name`).","example":"From Zero to Launch"},"instructor_name":{"type":["string","null"],"example":"Grace Hopper"},"completion_date":{"type":"string","description":"ISO 8601 date (YYYY-MM-DD) the student satisfied all completion requirements.","example":"2026-03-02"},"final_score":{"type":["integer","null"],"description":"Overall course score (0–100) when the course tracks a numeric grade; null otherwise.","example":92},"issued_at":{"type":"string","format":"date-time","example":"2026-03-02T18:00:00.000Z"},"expires_at":{"type":["string","null"],"format":"date-time","description":"Expiry timestamp for credentials with a shelf life. `null` = lifetime.","example":null},"pdf_url":{"type":["string","null"],"format":"uri","description":"Supabase Storage URL for the rendered PDF. May be null while generation is queued.","example":"https://cdn.hivelearn.app/certificates/cccc3333-dddd-4444-eeee-555555555555.pdf"},"created_at":{"type":"string","format":"date-time","example":"2026-03-02T18:00:00.000Z"},"updated_at":{"type":["string","null"],"format":"date-time","example":"2026-03-02T18:00:00.000Z"},"user":{"$ref":"#/components/schemas/CertificateUser"},"course":{"$ref":"#/components/schemas/CertificateCourse"}},"required":["id","community_id","course_id","user_id","certificate_code","status","recipient_name","course_title","instructor_name","completion_date","final_score","issued_at","expires_at","pdf_url","created_at","updated_at","user","course"]},"CertificateListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Certificate"}},"pagination":{"allOf":[{"$ref":"#/components/schemas/Pagination"},{"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":37}}}]}},"required":["data","pagination"]},"VerifyCertificateResponse":{"type":"object","properties":{"valid":{"type":"boolean","description":"`true` iff the certificate exists in this community, is not revoked, and has not expired.","example":true},"issued_at":{"type":"string","format":"date-time","description":"Present only when `valid` is true.","example":"2026-03-02T18:00:00.000Z"},"course_title":{"type":"string","description":"Present only when `valid` is true.","example":"From Zero to Launch"},"recipient_name":{"type":"string","description":"The name as engraved on the certificate. Present only when `valid` is true. This is the certificate's own `recipient_name` snapshot — not a live profile lookup — so no additional PII (email, phone, bio) is ever disclosed here.","example":"Ada Lovelace"}},"required":["valid"]},"CreateCertificateBody":{"type":"object","properties":{"user_id":{"type":"string","format":"uuid","description":"The recipient (profile id). Must be a member of the community that owns the API key — otherwise a 404 is returned.","example":"11111111-2222-3333-4444-555555555555"},"course_id":{"type":"string","format":"uuid","description":"The course this certificate certifies completion of. Must belong to the community that owns the API key — otherwise a 404 is returned.","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"expires_at":{"type":["string","null"],"format":"date-time","description":"Optional ISO 8601 expiry timestamp. Omit or pass `null` for a lifetime certificate.","example":"2030-03-02T18:00:00.000Z"},"completion_date":{"type":"string","pattern":"^\\d{4}-\\d{2}-\\d{2}$","description":"ISO 8601 date (YYYY-MM-DD) the student satisfied completion requirements. Defaults to today (UTC) when omitted — useful for back-dating imported or manually-issued certificates.","example":"2026-03-02"},"final_score":{"type":"integer","minimum":0,"maximum":100,"description":"Optional overall course score (0–100). Omit when the course is pass/fail.","example":92},"instructor_name":{"type":"string","minLength":1,"maxLength":255,"description":"Override the instructor name that appears on the certificate. When omitted the server snapshots `courses.instructor_name` at issue time.","example":"Grace Hopper"}},"required":["user_id","course_id"]},"UpdateCertificateBody":{"type":"object","properties":{"status":{"type":"string","enum":["active","revoked","expired"],"description":"New lifecycle status. The typical write is `'revoked'` — pulling a credential back after discovery of fraud or a grading error. `'active'` un-revokes. `'expired'` lets an admin manually expire before `expires_at`.","example":"revoked"}},"required":["status"]},"ResourceAuthor":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"11111111-2222-3333-4444-555555555555"},"full_name":{"type":["string","null"],"example":"Ada Lovelace"},"display_name":{"type":["string","null"],"example":"ada"},"avatar_url":{"type":["string","null"],"format":"uri","example":"https://cdn.hivelearn.app/avatars/ada.png"}},"required":["id","full_name","display_name","avatar_url"]},"Resource":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"community_id":{"type":"string","format":"uuid","example":"bbbb2222-cccc-3333-dddd-444444444444"},"author_id":{"type":"string","format":"uuid","example":"11111111-2222-3333-4444-555555555555"},"folder_id":{"type":["string","null"],"format":"uuid","description":"The resource_folders.id this resource lives in, or null for the root.","example":"cccc3333-dddd-4444-eeee-555555555555"},"title":{"type":"string","example":"Cold Outreach Playbook"},"description":{"type":["string","null"],"description":"Legacy plain-text body. May be empty when `description_json` is populated.","example":"A 12-step framework for booking discovery calls."},"description_json":{"description":"Tiptap JSON body (when `description_format` = 'tiptap_json').","example":{"type":"doc","content":[]}},"description_format":{"type":["string","null"],"description":"Either 'markdown' (legacy) or 'tiptap_json'.","example":"tiptap_json"},"resource_type":{"type":"string","enum":["guide","toolkit","course","checklist","template","other"],"example":"guide"},"resource_url":{"type":["string","null"],"description":"External link target when the resource is a link.","example":"https://example.com/playbook"},"file_url":{"type":["string","null"],"description":"Public file URL when the resource is an uploaded file. The internal R2 key is never exposed.","example":"https://cdn.hivelearn.app/files/playbook.pdf"},"cover_image_url":{"type":["string","null"],"example":"https://cdn.hivelearn.app/covers/playbook.png"},"tags":{"type":"array","items":{"type":"string"},"example":["sales","outreach"]},"access_level":{"type":["string","null"],"enum":["public","members","premium"],"example":"public"},"is_premium":{"type":"boolean","example":false},"price":{"type":["number","null"],"description":"One-time purchase price in the community's currency, or null if free.","example":29},"is_published":{"type":"boolean","example":true},"is_featured":{"type":"boolean","example":false},"downloads_count":{"type":"integer","example":142},"view_count":{"type":"integer","example":980},"favorites_count":{"type":["integer","null"],"example":17},"rating_count":{"type":["integer","null"],"example":23},"created_at":{"type":"string","format":"date-time","example":"2026-02-15T09:30:00.000Z"},"updated_at":{"type":["string","null"],"format":"date-time","example":"2026-02-16T11:05:00.000Z"},"author":{"$ref":"#/components/schemas/ResourceAuthor"}},"required":["id","community_id","author_id","folder_id","title","description","description_format","resource_type","resource_url","file_url","cover_image_url","tags","access_level","is_premium","price","is_published","is_featured","downloads_count","view_count","favorites_count","rating_count","created_at","updated_at","author"]},"ResourceListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Resource"}},"pagination":{"allOf":[{"$ref":"#/components/schemas/Pagination"},{"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":320}}}]}},"required":["data","pagination"]},"CreateResourceBody":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":200,"example":"Cold Outreach Playbook"},"resource_type":{"type":"string","enum":["guide","toolkit","course","checklist","template","other"],"default":"guide","example":"guide"},"description":{"type":"string","maxLength":20000,"example":"A 12-step framework for booking discovery calls."},"resource_url":{"type":"string","format":"uri","description":"Provide for external-link resources.","example":"https://example.com/playbook"},"file_url":{"type":"string","format":"uri","description":"Provide for file resources already uploaded to storage.","example":"https://cdn.hivelearn.app/files/playbook.pdf"},"folder_id":{"type":"string","format":"uuid","example":"cccc3333-dddd-4444-eeee-555555555555"},"cover_image_url":{"type":"string","format":"uri"},"tags":{"type":"array","items":{"type":"string","maxLength":50},"maxItems":20,"example":["sales","outreach"]},"access_level":{"type":"string","enum":["public","members","premium"],"default":"public","example":"public"},"is_premium":{"type":"boolean","default":false},"price":{"type":"number","exclusiveMinimum":0,"description":"One-time price (> 0) to charge for the resource. Omit (or null) for free — the DB rejects a price of 0.","example":29},"is_published":{"type":"boolean","default":true}},"required":["title"]},"UpdateResourceBody":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":200},"resource_type":{"type":"string","enum":["guide","toolkit","course","checklist","template","other"],"example":"guide"},"description":{"type":"string","maxLength":20000},"resource_url":{"type":"string","format":"uri"},"file_url":{"type":"string","format":"uri"},"folder_id":{"type":["string","null"],"format":"uuid"},"cover_image_url":{"type":["string","null"],"format":"uri"},"tags":{"type":"array","items":{"type":"string","maxLength":50},"maxItems":20},"access_level":{"type":"string","enum":["public","members","premium"],"example":"public"},"is_premium":{"type":"boolean"},"price":{"type":["number","null"],"exclusiveMinimum":0},"is_published":{"type":"boolean"},"is_featured":{"type":"boolean"}}},"LeaderboardEntry":{"type":"object","properties":{"user_id":{"type":"string","format":"uuid","example":"11111111-2222-3333-4444-555555555555"},"full_name":{"type":["string","null"],"example":"Ada Lovelace"},"display_name":{"type":["string","null"],"example":"ada"},"avatar_url":{"type":["string","null"],"format":"uri","example":"https://cdn.hivelearn.app/avatars/ada.png"},"rank":{"type":"integer","description":"1-based rank within the community for the requested period.","example":1},"points":{"type":"integer","description":"Points for the requested period (all-time total, 30-day, or 7-day).","example":4820},"total_points":{"type":"integer","example":4820},"points_this_month":{"type":"integer","example":610},"points_this_week":{"type":"integer","example":140},"current_level":{"type":"integer","example":9},"level_name":{"type":["string","null"],"example":"Elite"}},"required":["user_id","full_name","display_name","avatar_url","rank","points","total_points","points_this_month","points_this_week","current_level","level_name"]},"LeaderboardResponse":{"type":"object","properties":{"period":{"type":"string","enum":["all_time","30_day","7_day"],"example":"all_time"},"data":{"type":"array","items":{"$ref":"#/components/schemas/LeaderboardEntry"}},"pagination":{"$ref":"#/components/schemas/Pagination"}},"required":["period","data","pagination"]},"PointsBreakdown":{"type":"object","properties":{"user_id":{"type":"string","format":"uuid","example":"11111111-2222-3333-4444-555555555555"},"community_id":{"type":"string","format":"uuid","example":"bbbb2222-cccc-3333-dddd-444444444444"},"total_points":{"type":"integer","example":4820},"posts_points":{"type":"integer","example":1200},"comments_points":{"type":"integer","example":640},"events_points":{"type":"integer","example":300},"courses_points":{"type":"integer","example":2200},"engagement_points":{"type":"integer","example":480},"points_this_week":{"type":"integer","example":140},"points_this_month":{"type":"integer","example":610},"current_level":{"type":"integer","example":9},"level_name":{"type":["string","null"],"example":"Elite"},"last_points_awarded_at":{"type":["string","null"],"format":"date-time"},"updated_at":{"type":"string","format":"date-time"}},"required":["user_id","community_id","total_points","posts_points","comments_points","events_points","courses_points","engagement_points","points_this_week","points_this_month","current_level","level_name","last_points_awarded_at","updated_at"]},"Track":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"community_id":{"type":"string","format":"uuid","example":"bbbb2222-cccc-3333-dddd-444444444444"},"author_id":{"type":"string","format":"uuid","example":"11111111-2222-3333-4444-555555555555"},"title":{"type":"string","example":"Founder Fundamentals"},"description":{"type":["string","null"],"example":"Zero-to-one for first-time founders."},"thumbnail_url":{"type":["string","null"],"example":"https://cdn.hivelearn.app/tracks/ff.png"},"estimated_hours":{"type":["integer","null"],"example":12},"difficulty_level":{"type":["string","null"],"enum":["beginner","intermediate","advanced"],"example":"beginner"},"is_published":{"type":"boolean","example":true},"is_featured":{"type":"boolean","example":false},"created_at":{"type":"string","format":"date-time","example":"2026-01-10T15:00:00.000Z"},"updated_at":{"type":["string","null"],"format":"date-time"}},"required":["id","community_id","author_id","title","description","thumbnail_url","estimated_hours","difficulty_level","is_published","is_featured","created_at","updated_at"]},"TrackListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Track"}},"pagination":{"allOf":[{"$ref":"#/components/schemas/Pagination"},{"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":12}}}]}},"required":["data","pagination"]},"TrackCourse":{"type":"object","properties":{"id":{"type":"string","format":"uuid","example":"dddd4444-eeee-5555-ffff-666666666666"},"track_id":{"type":"string","format":"uuid"},"course_id":{"type":"string","format":"uuid"},"sort_order":{"type":"integer","example":0},"is_required":{"type":"boolean","example":true},"unlock_after_course_id":{"type":["string","null"],"format":"uuid","description":"Prerequisite course that must be completed before this one unlocks."},"course":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"title":{"type":"string","example":"Validating Your Idea"}},"required":["id","title"]}},"required":["id","track_id","course_id","sort_order","is_required","unlock_after_course_id","course"]},"TrackCourseListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/TrackCourse"}}},"required":["data"]},"TrackProgress":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"track_id":{"type":"string","format":"uuid"},"enrolled_at":{"type":["string","null"],"format":"date-time"},"started_at":{"type":["string","null"],"format":"date-time"},"completed_at":{"type":["string","null"],"format":"date-time"},"total_courses":{"type":"integer","example":5},"completed_courses":{"type":"integer","example":2},"progress_percentage":{"type":"integer","example":40},"last_accessed_at":{"type":["string","null"],"format":"date-time"}},"required":["id","user_id","track_id","enrolled_at","started_at","completed_at","total_courses","completed_courses","progress_percentage","last_accessed_at"]},"TrackProgressListResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/TrackProgress"}},"pagination":{"allOf":[{"$ref":"#/components/schemas/Pagination"},{"properties":{"limit":{"type":"integer","example":50},"offset":{"type":"integer","example":0},"total":{"type":"integer","example":12}}}]}},"required":["data","pagination"]},"CreateTrackBody":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":200,"example":"Founder Fundamentals"},"description":{"type":"string","maxLength":20000},"thumbnail_url":{"type":"string","format":"uri"},"estimated_hours":{"type":"integer","minimum":0},"difficulty_level":{"type":"string","enum":["beginner","intermediate","advanced"],"example":"beginner"},"is_published":{"type":"boolean","default":false},"is_featured":{"type":"boolean","default":false}},"required":["title"]},"UpdateTrackBody":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":200},"description":{"type":"string","maxLength":20000},"thumbnail_url":{"type":["string","null"],"format":"uri"},"estimated_hours":{"type":["integer","null"],"minimum":0},"difficulty_level":{"type":"string","enum":["beginner","intermediate","advanced"],"example":"beginner"},"is_published":{"type":"boolean"},"is_featured":{"type":"boolean"}}},"AddTrackCourseBody":{"type":"object","properties":{"course_id":{"type":"string","format":"uuid","example":"eeee5555-ffff-6666-aaaa-777777777777"},"sort_order":{"type":"integer","minimum":0,"description":"Position within the track.","example":0},"is_required":{"type":"boolean","default":true},"unlock_after_course_id":{"type":["string","null"],"format":"uuid"}},"required":["course_id","sort_order"]}},"parameters":{}},"paths":{"/api/v1/communities/me":{"get":{"tags":["Communities"],"summary":"Get the community associated with the API key","description":"Returns the community that owns the API key used in this request. Useful as an identity check and to confirm the key is active.","security":[{"ApiKeyAuth":[]}],"responses":{"200":{"description":"The community associated with the API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Community"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Community not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/members":{"get":{"tags":["Members"],"summary":"List members of the community associated with the API key","description":"Returns a paginated list of members belonging to the community that owns the API key. Supports filtering by role. The `ban_reason` and `banned_at` fields are intentionally never exposed via the public API.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"integer","minimum":1,"maximum":100,"default":50,"description":"Max results per page (1–100, default 50).","example":50},"required":false,"name":"limit","in":"query"},{"schema":{"type":["integer","null"],"minimum":0,"default":0,"description":"Number of rows to skip for pagination.","example":0},"required":false,"name":"offset","in":"query"},{"schema":{"type":"string","enum":["member","moderator","admin","owner"],"description":"Filter members by role.","example":"member"},"required":false,"name":"role","in":"query"}],"responses":{"200":{"description":"Paginated list of members","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MemberListResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/members/{id}":{"get":{"tags":["Members"],"summary":"Get a single member by membership id","description":"Returns the community_members row identified by `id`. Returns 404 if the row does not exist or belongs to a different community than the API key's.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The community_members.id (membership row UUID).","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"The member","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Member"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Member not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"patch":{"tags":["Members"],"summary":"Update a member's role or ban status","description":"Updates the membership row identified by `id`. Supports changing `role` (to member, moderator, or admin) and toggling `is_banned`. The owner role cannot be assigned or modified via the API. Returns 404 if the membership does not exist or belongs to a different community than the API key's.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The community_members.id (membership row UUID).","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMemberBody"}}}},"responses":{"200":{"description":"The updated member","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Member"}}}},"400":{"description":"Invalid body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — missing scope or attempt to modify an owner","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Member not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Members"],"summary":"Remove a member from the community","description":"Removes the membership row identified by `id` from the API key's community. The community owner cannot be removed via the API. Returns 204 on success and 404 if the membership does not exist or belongs to a different community.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The community_members.id (membership row UUID).","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"Removed (no body)"},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — missing scope or attempt to remove an owner","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Member not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/posts":{"get":{"tags":["Posts"],"summary":"List posts in the community associated with the API key","description":"Returns a paginated list of published posts belonging to the community that owns the API key, ordered by `created_at` descending (newest first). Only posts where `is_published = true` are returned; drafts and soft-deleted rows are hidden. Each post is enriched with a minimal `author` object (id, full_name, display_name, avatar_url). Use the optional `category` query parameter to filter by a specific category label.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"integer","minimum":1,"maximum":100,"default":50,"description":"Max results per page (1–100, default 50).","example":50},"required":false,"name":"limit","in":"query"},{"schema":{"type":["integer","null"],"minimum":0,"default":0,"description":"Number of rows to skip for pagination.","example":0},"required":false,"name":"offset","in":"query"},{"schema":{"type":"string","minLength":1,"maxLength":100,"description":"Filter posts by category (exact match on the `category` text column). Omit to return all categories.","example":"General"},"required":false,"name":"category","in":"query"}],"responses":{"200":{"description":"Paginated list of posts","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostListResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"post":{"tags":["Posts"],"summary":"Create a new post in the community","description":"Creates a post attributed to the user who owns the API key (stored in `api_keys.created_by`). The new post is auto-published (`is_published = true`) and scoped to the API key's community. At least one of `content` or `content_json` must be supplied.","security":[{"ApiKeyAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePostBody"}}}},"responses":{"201":{"description":"The newly created post, with joined author profile","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Post"}}}},"400":{"description":"Invalid body — neither content nor content_json provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/posts/{id}":{"get":{"tags":["Posts"],"summary":"Get a single post by id","description":"Returns the post identified by `id`. Returns 404 if the post does not exist, is unpublished, or belongs to a different community than the API key's. The response shape matches a single element of `GET /v1/posts` (author profile joined in).","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `posts.id` (UUID).","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"The post","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Post"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Post not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"patch":{"tags":["Posts"],"summary":"Update a post","description":"Partially updates a post. Only fields present in the body are changed; `updated_at` is bumped to `now()` automatically. Returns 404 if the post does not exist or belongs to a different community than the API key's.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `posts.id` (UUID).","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePostBody"}}}},"responses":{"200":{"description":"The updated post","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Post"}}}},"400":{"description":"Invalid body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Post not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Posts"],"summary":"Delete a post","description":"Permanently deletes a post. Matches the in-app convention in src/hooks/usePostMutations.js (hard delete). Returns 204 on success and 404 if the post does not exist or belongs to a different community.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `posts.id` (UUID).","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"Deleted (no body)"},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Post not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/events":{"get":{"tags":["Events"],"summary":"List events in the community associated with the API key","description":"Returns a paginated list of published events belonging to the community that owns the API key, ordered by `start_date` ascending (so the next upcoming event appears first). Only events where `is_published = true` are returned; drafts are hidden, but cancelled events remain visible with `is_cancelled = true` so callers can render cancellation notices. Each event is enriched with a minimal `host` object (id, full_name, display_name, avatar_url). Pass `upcoming=true` to exclude events whose `start_date` is in the past.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"integer","minimum":1,"maximum":100,"default":50,"description":"Max results per page (1–100, default 50).","example":50},"required":false,"name":"limit","in":"query"},{"schema":{"type":["integer","null"],"minimum":0,"default":0,"description":"Number of rows to skip for pagination.","example":0},"required":false,"name":"offset","in":"query"},{"schema":{"type":["boolean","null"],"default":false,"description":"When `true`, only events whose `start_date` is greater than or equal to the current server time are returned. Default `false` (returns all events).","example":true},"required":false,"name":"upcoming","in":"query"}],"responses":{"200":{"description":"Paginated list of events","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventListResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"post":{"tags":["Events"],"summary":"Create a new event in the community","description":"Creates an event hosted by the user who owns the API key (stored in `api_keys.created_by`). The event is auto-published (`is_published = true`), not cancelled (`is_cancelled = false`), and starts with `current_attendees = 0`. Validates that `end_date` is strictly after `start_date`.","security":[{"ApiKeyAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateEventBody"}}}},"responses":{"201":{"description":"The newly created event, with joined host profile","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event"}}}},"400":{"description":"Invalid body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/events/{id}":{"get":{"tags":["Events"],"summary":"Get a single event by id","description":"Returns the event identified by `id`. Returns 404 if the event does not exist, is unpublished, or belongs to a different community than the API key's. The response shape matches a single element of `GET /v1/events` (host profile joined in).","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `events.id` (UUID).","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"The event","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Event not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"patch":{"tags":["Events"],"summary":"Update an event","description":"Partially updates an event. Only fields present in the body are changed. `updated_at` is bumped to `now()` automatically. To cancel without deleting, pass `{ \"is_cancelled\": true }`. Returns 404 if the event does not exist or belongs to a different community.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `events.id` (UUID).","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateEventBody"}}}},"responses":{"200":{"description":"The updated event","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Event"}}}},"400":{"description":"Invalid body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Event not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Events"],"summary":"Cancel an event (soft delete)","description":"Soft-deletes an event by setting `is_cancelled = true`. This matches the in-app convention in src/hooks/useEvents.js — the row is preserved so attendees still see the cancellation. To fully change or reschedule, use PATCH with `is_cancelled: false` or update fields.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `events.id` (UUID).","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"Cancelled (no body)"},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Event not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/courses":{"get":{"tags":["Courses"],"summary":"List courses in the community associated with the API key","description":"Returns a paginated list of courses belonging to the community that owns the API key, ordered by `created_at` descending (newest first). Each course is enriched with a minimal `author` object (id, full_name, display_name, avatar_url). Use the optional `status` query parameter to filter by publish state — 'published' (is_published=true), 'draft' (is_published=false), or 'archived' (reserved for future use; currently returns an empty list). To fetch the module/lesson tree for a specific course, see `GET /v1/courses/{courseId}/modules` and `GET /v1/courses/{courseId}/lessons`.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"integer","minimum":1,"maximum":100,"default":50,"description":"Max results per page (1–100, default 50).","example":50},"required":false,"name":"limit","in":"query"},{"schema":{"type":["integer","null"],"minimum":0,"default":0,"description":"Number of rows to skip for pagination.","example":0},"required":false,"name":"offset","in":"query"},{"schema":{"type":"string","enum":["published","draft","archived"],"description":"Filter courses by publish state. Maps to the underlying `is_published` column: 'published' → is_published = true; 'draft' → is_published = false. 'archived' is reserved — no archived state is tracked yet so it always returns empty.","example":"published"},"required":false,"name":"status","in":"query"}],"responses":{"200":{"description":"Paginated list of courses","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CourseListResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"post":{"tags":["Courses"],"summary":"Create a new course","description":"Creates a course attributed to the user who owns the API key (stored in `api_keys.created_by`, written to `courses.author_id`). New courses are created as drafts (`is_published = false`) so authors can build out modules and lessons before publishing. Pass `is_published: true` via PATCH when ready. `community_id` is set from the API key's community; counts (`lessons_count`, `enrollment_count`) start at 0.","security":[{"ApiKeyAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCourseInput"}}}},"responses":{"201":{"description":"The newly created course, with joined author profile","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Course"}}}},"400":{"description":"Invalid body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/courses/{id}":{"get":{"tags":["Courses"],"summary":"Get a single course by id","description":"Returns the course identified by `id`. Returns 404 if the course does not exist or belongs to a different community than the API key's. The response shape matches a single element of `GET /v1/courses` (author profile joined in). Unlike the list endpoint, both published and draft courses are returned — use the `is_published` field on the response to distinguish them.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `courses.id` (UUID).","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"The course","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Course"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Course not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"patch":{"tags":["Courses"],"summary":"Update a course","description":"Partially updates a course. Only fields present in the body are changed; `updated_at` is bumped to `now()` automatically. Returns 404 if the course does not exist or belongs to a different community than the API key's.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `courses.id` (UUID).","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCourseInput"}}}},"responses":{"200":{"description":"The updated course","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Course"}}}},"400":{"description":"Invalid body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Course not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Courses"],"summary":"Delete a course","description":"Permanently deletes a course. All child `course_sections` and `lessons` are cascade-deleted per the FK constraints. Matches the in-app convention in src/hooks/useCourses.js::deleteCourse (hard delete). Returns 204 on success and 404 if the course does not exist or belongs to a different community.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `courses.id` (UUID).","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"Deleted (no body)"},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Course not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/courses/{courseId}/modules":{"get":{"tags":["Courses"],"summary":"List modules for a course","description":"Returns every module (a.k.a. section) belonging to the given course, ordered by `sort_order` ascending. The parent course must belong to the community that owns the API key — otherwise a 404 is returned. Modules are the grouping primitive inside a course; individual lessons are fetched via `GET /v1/courses/{courseId}/lessons`. Note: internally this resource is stored in the `course_sections` table.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `courses.id` (UUID) that owns the nested resources.","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"courseId","in":"path"}],"responses":{"200":{"description":"List of modules (not paginated — a course rarely has more than a few dozen).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ModuleListResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Course not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"post":{"tags":["Courses"],"summary":"Create a module (section) inside a course","description":"Adds a new module to the given course. The parent course must belong to the community that owns the API key — otherwise a 404 is returned. When `sort_order` is omitted the server appends the module to the end (max + 1, or 0 if the course has no modules yet). Internally modules are stored in the `course_sections` table.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `courses.id` (UUID) that owns the nested resources.","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"courseId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateModuleInput"}}}},"responses":{"201":{"description":"The newly created module","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Module"}}}},"400":{"description":"Invalid body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Course not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/courses/{courseId}/lessons":{"get":{"tags":["Courses"],"summary":"List lessons for a course","description":"Returns every lesson belonging to the given course as a flat list. Each lesson carries its `module_id` so consumers can group lessons under their parent module client-side. The ordering sorts by `sort_order` ascending so the natural curriculum order is preserved (note: modules are NOT sorted ahead of lessons in this endpoint — pair with `GET /v1/courses/{courseId}/modules` to reconstruct a module-then-lesson outline). Pass the optional `module_id` query parameter to filter lessons to a single module. The parent course must belong to the community that owns the API key — otherwise a 404 is returned. Lessons with `module_id = null` are unsorted/staging lessons.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `courses.id` (UUID) that owns the nested resources.","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"courseId","in":"path"},{"schema":{"type":"string","format":"uuid","description":"Restrict lessons to those belonging to a specific module. Omit to return every lesson for the course (including unsorted lessons where `module_id` is null).","example":"cccc3333-dddd-4444-eeee-555555555555"},"required":false,"name":"module_id","in":"query"}],"responses":{"200":{"description":"List of lessons for the course (not paginated).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LessonListResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Course not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"post":{"tags":["Courses"],"summary":"Create a lesson inside a course","description":"Adds a new lesson to the given course. The parent course must belong to the community that owns the API key — otherwise a 404 is returned. Pass `module_id` to place the lesson inside a specific module, or omit it (or send `null`) to stage the lesson in the unsorted bucket. When `sort_order` is omitted the server appends the lesson to the end of its target container. `community_id` is inferred from the parent course and written into `lessons.community_id` (which is NOT NULL).","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `courses.id` (UUID) that owns the nested resources.","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"courseId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateLessonInput"}}}},"responses":{"201":{"description":"The newly created lesson","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Lesson"}}}},"400":{"description":"Invalid body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Course not found (or referenced module not in the course)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/modules/{id}":{"patch":{"tags":["Courses"],"summary":"Update a module","description":"Partially updates a module (course section). The parent course is looked up and verified against the API key's community before the update runs — returns 404 if the module doesn't exist, its course is missing, or the course belongs to a different community.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The module UUID (stored internally as `course_sections.id`).","example":"cccc3333-dddd-4444-eeee-555555555555"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateModuleInput"}}}},"responses":{"200":{"description":"The updated module","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Module"}}}},"400":{"description":"Invalid body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Module not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Courses"],"summary":"Delete a module","description":"Permanently deletes a module. Lessons belonging to the module are NOT deleted — per the `ON DELETE SET NULL` FK on `lessons.section_id`, they revert to the unsorted/staging bucket. Returns 204 on success and 404 if the module does not exist or its parent course belongs to a different community.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The module UUID (stored internally as `course_sections.id`).","example":"cccc3333-dddd-4444-eeee-555555555555"},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"Deleted (no body)"},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Module not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/lessons/{id}":{"patch":{"tags":["Courses"],"summary":"Update a lesson","description":"Partially updates a lesson. The lesson's community is re-verified against the API key — returns 404 if the lesson does not exist or belongs to a different community. `updated_at` is bumped to `now()` automatically.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `lessons.id` (UUID).","example":"dddd4444-eeee-5555-ffff-666666666666"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateLessonInput"}}}},"responses":{"200":{"description":"The updated lesson","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Lesson"}}}},"400":{"description":"Invalid body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Lesson not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Courses"],"summary":"Delete a lesson","description":"Permanently deletes a lesson. Returns 204 on success and 404 if the lesson does not exist or belongs to a different community.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `lessons.id` (UUID).","example":"dddd4444-eeee-5555-ffff-666666666666"},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"Deleted (no body)"},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Lesson not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/courses/outline":{"post":{"tags":["Courses"],"summary":"Scaffold a course, its modules, and all lessons in a single call","description":"Atomic batch-create designed for AI agents building whole courses from a single prompt. Creates the course (draft), then all modules in order, then all lessons attached to their owning modules. Lesson `content_url` is optional here — omit it to scaffold titles-only and fill content later via PATCH /v1/lessons/{id}/content. On any child failure the course is cascade-deleted to avoid orphans. Returns the full course object plus a nested tree of created module and lesson IDs so the agent can address each one directly in follow-up calls.","security":[{"ApiKeyAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCourseOutlineInput"}}}},"responses":{"201":{"description":"The scaffolded course with module and lesson IDs","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CourseOutlineResponse"}}}},"400":{"description":"Invalid body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/lessons/{id}/content":{"patch":{"tags":["Courses"],"summary":"Update a lesson's media and body content only","description":"Partial update of content-related fields only: `content_url`, `content_type`, `content_json`, `content_format`, `description`, `duration_seconds`, `thumbnail_url`. Does NOT change title, is_preview, is_published, sort_order, or module membership — use PATCH /v1/lessons/{id} for those. Separating the endpoints lets agents regenerate a long markdown body without re-sending the title, and lets them fix a typo in the title without re-sending the body.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `lessons.id` (UUID).","example":"dddd4444-eeee-5555-ffff-666666666666"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateLessonContentInput"}}}},"responses":{"200":{"description":"The updated lesson","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Lesson"}}}},"400":{"description":"Invalid body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Lesson not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/modules/{id}/reorder":{"post":{"tags":["Courses"],"summary":"Reorder the lessons inside a module","description":"Accepts the full ordered list of lesson UUIDs that belong to the module. Server assigns sort_order = array index (0-based). All provided lesson IDs must currently belong to the target module — if any don't, a 400 is returned and no updates are applied. Lessons omitted from the list are not reordered; only the provided ones are repositioned. Designed to match agent cognition (\"here's the final order\") rather than incremental nudges.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The module UUID (stored internally as `course_sections.id`).","example":"cccc3333-dddd-4444-eeee-555555555555"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReorderLessonsInput"}}}},"responses":{"200":{"description":"Reorder complete","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReorderLessonsResponse"}}}},"400":{"description":"Invalid body, or one or more lesson IDs don't belong to the module","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Module not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/courses/{id}/publish":{"post":{"tags":["Courses"],"summary":"Publish a draft course","description":"Flips `is_published = true` on the course. Validates that the course has at least one module and at least one lesson before publishing — rejects with 400 otherwise, to avoid surfacing empty courses to members. This is a deliberate safety separation from PATCH /v1/courses/{id}: agents scaffolding drafts can't accidentally go live with a single typo in a flag name.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `courses.id` (UUID).","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"The published course","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Course"}}}},"400":{"description":"Course has no modules or no lessons — cannot publish an empty course","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Course not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/courses/{id}/structure":{"get":{"tags":["Courses"],"summary":"Get a compact titles-only map of a course's modules and lessons","description":"Returns the course (id + title + publish state), its modules with titles + sort_order, and for each module the lessons (id + title + content_type + sort_order + is_published). Staged/unsorted lessons (module_id = null) are returned in a separate `unsorted_lessons` array. Use this to orient an agent before edits without paying the cost of fetching every lesson body — a 30-lesson course returns in a few KB instead of tens of KB.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `courses.id` (UUID).","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"The nested course structure","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CourseStructureResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Course not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/quizzes":{"get":{"tags":["Quizzes"],"summary":"List quizzes in the community associated with the API key","description":"Returns a paginated list of quizzes belonging to the community that owns the API key, ordered by `created_at` descending (newest first). Optional `course_id` and `lesson_id` query parameters narrow the result to quizzes attached to a specific course or lesson — they combine as an AND filter when both are provided. Questions are NOT inlined in this response — fetch them separately via `GET /v1/quizzes/{quizId}/questions` once you know the quiz id. This keeps the listing cheap even for communities with hundreds of quizzes.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"integer","minimum":1,"maximum":100,"default":50,"description":"Max results per page (1–100, default 50).","example":50},"required":false,"name":"limit","in":"query"},{"schema":{"type":["integer","null"],"minimum":0,"default":0,"description":"Number of rows to skip for pagination.","example":0},"required":false,"name":"offset","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Restrict the list to quizzes attached to the given course. Combine with `lesson_id` to narrow further — both filters AND together.","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":false,"name":"course_id","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Restrict the list to quizzes attached to the given lesson.","example":"dddd4444-eeee-5555-ffff-666666666666"},"required":false,"name":"lesson_id","in":"query"}],"responses":{"200":{"description":"Paginated list of quizzes","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizListResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"post":{"tags":["Quizzes"],"summary":"Create a new quiz attached to a lesson","description":"Creates a quiz scoped to the community that owns the API key. The quiz is attached to the provided `lesson_id` — the lesson must belong to the same community (otherwise a 404 is returned). The quiz's `course_id` is denormalised from the parent lesson. Questions are created via the separate `POST /v1/quizzes/{quizId}/questions` endpoint; newly-created quizzes are always drafts (`is_published = false`) and must be published via a future PATCH before members can attempt them.","security":[{"ApiKeyAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateQuizBody"}}}},"responses":{"201":{"description":"The created quiz","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Quiz"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Lesson not found in this community","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/quizzes/{id}":{"get":{"tags":["Quizzes"],"summary":"Get a single quiz by id","description":"Returns the quiz identified by `id`. Returns 404 if the quiz does not exist or belongs to a different community than the API key's. Both published and draft quizzes are returned — check the `is_published` field if you need to filter out drafts. Questions are not inlined; fetch them via `GET /v1/quizzes/{quizId}/questions`.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `quizzes.id` (UUID).","example":"77777777-aaaa-bbbb-cccc-111111111111"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"The quiz","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Quiz"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Quiz not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"patch":{"tags":["Quizzes"],"summary":"Update an existing quiz","description":"Partially updates the quiz identified by `id`. Only the fields present in the request body are modified. `lesson_id` is intentionally NOT updatable via this endpoint — moving a quiz between lessons is not a supported write-API operation. Returns 404 if the quiz does not exist or belongs to a different community.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `quizzes.id` (UUID).","example":"77777777-aaaa-bbbb-cccc-111111111111"},"required":true,"name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateQuizBody"}}}},"responses":{"200":{"description":"The updated quiz","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Quiz"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Quiz not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Quizzes"],"summary":"Delete a quiz (cascades to its questions)","description":"Permanently deletes the quiz identified by `id`. All `quiz_questions` belonging to the quiz are removed via `ON DELETE CASCADE`. Existing attempts in `quiz_attempts` are also cascaded — deleting a quiz erases its answer history. Returns 404 if the quiz does not exist or belongs to a different community.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `quizzes.id` (UUID).","example":"77777777-aaaa-bbbb-cccc-111111111111"},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"Deleted (no body)"},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Quiz not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/quizzes/{quizId}/questions":{"get":{"tags":["Quizzes"],"summary":"List questions for a quiz","description":"Returns every question belonging to the given quiz, ordered by `sort_order` ascending. The parent quiz must belong to the community that owns the API key — otherwise a 404 is returned (identical to the behaviour of `GET /v1/quizzes/{id}`). \n\n**Answer-integrity guarantee:** the public API never reveals which option is correct. Each option in the response contains only `id` and `text`; the underlying `is_correct` flag stored in the `quiz_questions.options` JSONB is stripped before the response is serialised. The question's `explanation` column (which often paraphrases the correct answer) is also never selected. Attempts must be scored server-side via the internal `calculate_quiz_score` RPC — NOT by comparing a client's answer against a locally-known correct option.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `quizzes.id` (UUID) that owns the nested questions.","example":"77777777-aaaa-bbbb-cccc-111111111111"},"required":true,"name":"quizId","in":"path"}],"responses":{"200":{"description":"List of questions for the quiz (not paginated).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizQuestionListResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Quiz not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"post":{"tags":["Quizzes"],"summary":"Add a question to a quiz","description":"Appends a new question to the quiz identified by `quizId`. The parent quiz must belong to the community that owns the API key. `sort_order` is auto-assigned when omitted (appended to the end). \n\n**Answer-integrity guarantee:** the response mirrors the GET convention — `is_correct` is stripped from every option, and `explanation` is never returned. The caller just wrote those values, but keeping the response shape identical prevents them being accidentally cached and served back to learners through a consumer-side proxy.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `quizzes.id` (UUID) that owns the nested questions.","example":"77777777-aaaa-bbbb-cccc-111111111111"},"required":true,"name":"quizId","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateQuizQuestionBody"}}}},"responses":{"201":{"description":"The created question","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizQuestion"}}}},"400":{"description":"Bad Request (missing options, no correct option, etc.)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Quiz not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/questions/{id}":{"patch":{"tags":["Quizzes"],"summary":"Update a quiz question","description":"Partially updates the question identified by `id`. The parent quiz's community is re-verified on every call — a question whose parent quiz belongs to a different community returns 404. When `question_type` is changed, the relevant body field for the new type becomes required (`options` for multiple_choice, `correct_answer` for true_false). The response strips `is_correct` from options and omits `explanation`, matching the read convention.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `quiz_questions.id` (UUID).","example":"99999999-aaaa-bbbb-cccc-222222222222"},"required":true,"name":"id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateQuizQuestionBody"}}}},"responses":{"200":{"description":"The updated question","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizQuestion"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Question not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Quizzes"],"summary":"Delete a quiz question","description":"Permanently deletes the question identified by `id`. The parent quiz's community is re-verified before deletion. Returns 404 if the question does not exist or belongs to a different community.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `quiz_questions.id` (UUID).","example":"99999999-aaaa-bbbb-cccc-222222222222"},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"Deleted (no body)"},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Question not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/enrollments":{"get":{"tags":["Enrollments"],"summary":"List enrollments in the community associated with the API key","description":"Returns a paginated list of course enrollments belonging to the community that owns the API key, ordered by `enrolled_at` descending (newest first). Each row is enriched with a minimal `student` profile (id, full_name, display_name, avatar_url) and a derived `status` field ('not_started' | 'in_progress' | 'completed') computed from the underlying `completed_at` and `progress_percentage` columns. Use the optional `course_id` and `user_id` query parameters to narrow the result set — they combine as an AND filter. For per-lesson granularity see `GET /v1/courses/{courseId}/progress`; for aggregated quiz performance see `GET /v1/courses/{courseId}/gradebook`.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"integer","minimum":1,"maximum":100,"default":50,"description":"Max results per page (1–100, default 50).","example":50},"required":false,"name":"limit","in":"query"},{"schema":{"type":["integer","null"],"minimum":0,"default":0,"description":"Number of rows to skip for pagination.","example":0},"required":false,"name":"offset","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Restrict to enrollments in the given course. Combines as an AND with `user_id`.","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":false,"name":"course_id","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Restrict to enrollments belonging to the given student. Combines as an AND with `course_id`.","example":"11111111-2222-3333-4444-555555555555"},"required":false,"name":"user_id","in":"query"}],"responses":{"200":{"description":"Paginated list of enrollments","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnrollmentListResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"post":{"tags":["Enrollments"],"summary":"Enroll a user in a course","description":"Enrolls the given user in the given course. The API key's community owns the write: the target course MUST belong to that community, and the target user MUST already be a community member — otherwise a 404 is returned (we do not create cross-tenant enrollments, and we do not auto-join users into communities from this endpoint). \n\n**Idempotency.** `course_enrollments` has a UNIQUE(user_id, course_id) constraint. If the pair is already enrolled, this endpoint returns **200 OK** with the existing enrollment row — not 409 Conflict. This matches the common automation case where the caller wants 'ensure enrolled' semantics and doesn't want to branch on conflict.","security":[{"ApiKeyAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateEnrollmentBody"}}}},"responses":{"200":{"description":"The existing enrollment row (user was already enrolled in this course). Same shape as `GET /v1/enrollments/{id}`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Enrollment"}}}},"201":{"description":"A newly created enrollment row. Same shape as `GET /v1/enrollments/{id}`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Enrollment"}}}},"400":{"description":"Invalid body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Course not found in this community, or user is not a member of this community","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/enrollments/{id}":{"get":{"tags":["Enrollments"],"summary":"Get a single enrollment by id","description":"Returns the `course_enrollments` row identified by `id`. Returns 404 if the row does not exist or belongs to a different community than the API key's. The response shape matches a single element of `GET /v1/enrollments` (student profile joined in, `status` derived).","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `course_enrollments.id` (UUID).","example":"eeee5555-ffff-6666-aaaa-777777777777"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"The enrollment","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Enrollment"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Enrollment not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Enrollments"],"summary":"Un-enroll a user from a course","description":"Permanently removes an enrollment row. Returns 204 on success, 404 if the enrollment does not exist or belongs to a different community than the API key's. Note: deleting an enrollment does NOT delete the student's per-lesson `course_progress` rows or any quiz attempts — those are kept as a historical record and can be inspected via `GET /v1/courses/{courseId}/progress`.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `course_enrollments.id` (UUID).","example":"eeee5555-ffff-6666-aaaa-777777777777"},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"Deleted (no body)"},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Enrollment not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/courses/{courseId}/progress":{"get":{"tags":["Gradebook"],"summary":"List per-lesson completion records for a course","description":"Returns every row from the underlying `course_progress` table scoped to the given course, joined to the completing student's profile. Each row represents a single lesson the student has marked complete — the table does not track partial progress, so a student with no row for a lesson simply has no progress entity at all (not a row with `is_completed: false`). The parent course must belong to the community that owns the API key, otherwise a 404 is returned. Pass the optional `user_id` query parameter to scope the response to a single student's progress through the course. Ordered by `completed_at` descending so the most recent completions surface first.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `courses.id` (UUID) that owns the nested resources.","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"courseId","in":"path"},{"schema":{"type":"integer","minimum":1,"maximum":100,"default":50,"description":"Max results per page (1–100, default 50).","example":50},"required":false,"name":"limit","in":"query"},{"schema":{"type":["integer","null"],"minimum":0,"default":0,"description":"Number of rows to skip for pagination.","example":0},"required":false,"name":"offset","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Filter to a single student's progress rows in this course. Omit to return progress rows for every enrolled student.","example":"11111111-2222-3333-4444-555555555555"},"required":false,"name":"user_id","in":"query"}],"responses":{"200":{"description":"Paginated list of lesson-completion records for the course.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LessonProgressListResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Course not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/courses/{courseId}/gradebook":{"get":{"tags":["Gradebook"],"summary":"Aggregated gradebook for every enrolled student in a course","description":"Returns one row per enrolled student summarising their activity on the course: enrollment status, timestamps, completed-lessons snapshot from the enrollment row, quiz-attempt count, and arithmetic-mean quiz score across COMPLETED attempts. In-progress quiz attempts are excluded from both the count and the average. `avg_quiz_score` is `null` when the student has zero completed attempts. The parent course must belong to the community that owns the API key, otherwise a 404 is returned. Pass `user_id` to scope the response to a single student. \n\n**Implementation note:** this endpoint batches three round-trips (enrollments, course-scoped quiz ids via a quizzes lookup, and a quiz-attempt fetch) and aggregates in application code rather than via a CTE-style query. PostgREST doesn't cleanly express per-group aggregates across two dependent joins (enrollments → quiz_attempts ← quizzes), so in-memory aggregation keeps the query cost predictable and avoids a bespoke RPC function. Large cohorts should page through with the `limit`/`offset` parameters to bound payload size.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `courses.id` (UUID) that owns the nested resources.","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"courseId","in":"path"},{"schema":{"type":"integer","minimum":1,"maximum":100,"default":50,"description":"Max students per page (1–100, default 50). The gradebook aggregates quiz attempts across every enrolled student, so large cohorts should page through rather than requesting the full list in one call.","example":50},"required":false,"name":"limit","in":"query"},{"schema":{"type":["integer","null"],"minimum":0,"default":0,"description":"Number of students to skip for pagination.","example":0},"required":false,"name":"offset","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Return a single-row gradebook scoped to one student.","example":"11111111-2222-3333-4444-555555555555"},"required":false,"name":"user_id","in":"query"}],"responses":{"200":{"description":"Paginated list of per-student gradebook rows. `total` on the pagination object reflects the total enrolled-student count, not attempt rows.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GradebookListResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Course not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/certificates":{"get":{"tags":["Certificates"],"summary":"List certificates issued in the community associated with the API key","description":"Returns a paginated list of certificates belonging to the community that owns the API key, ordered by `issued_at` descending (newest first). Each row is enriched with a minimal `user` profile (id, full_name, display_name, avatar_url — never email, phone, or bio) and a minimal `course` object (id, title). Use the optional `user_id` and `course_id` query parameters to narrow the result set — they combine as an AND filter.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"integer","minimum":1,"maximum":100,"default":50,"description":"Max results per page (1–100, default 50).","example":50},"required":false,"name":"limit","in":"query"},{"schema":{"type":["integer","null"],"minimum":0,"default":0,"description":"Number of rows to skip for pagination.","example":0},"required":false,"name":"offset","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Restrict to certificates issued to the given user. Combines as AND with `course_id`.","example":"11111111-2222-3333-4444-555555555555"},"required":false,"name":"user_id","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Restrict to certificates for the given course. Combines as AND with `user_id`.","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":false,"name":"course_id","in":"query"}],"responses":{"200":{"description":"Paginated list of certificates","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CertificateListResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"post":{"tags":["Certificates"],"summary":"Issue a certificate (admin)","description":"Admin-issued certificate creation for a user and course. The target course must belong to the community that owns the API key, and the target user must be a member of that community — otherwise a 404 is returned. \n\n**Snapshots.** At issue time the server captures `recipient_name` from `profiles.full_name` and `course_title` from `courses.title`. These are stored on the certificate row and are NOT refreshed if the profile or course is later renamed — by design, a certificate is a statement about a point in time. \n\n**Codes.** `certificate_code` (format `CERT-XXXXXX-YYYY`) and `verification_token` (32-byte hex) are generated by the database's `set_certificate_defaults` trigger. `verification_token` is never returned — it is an internal secret paired with the public `certificate_code`. \n\n**Idempotency.** `certificates` has a UNIQUE(user_id, course_id, community_id) constraint. If a certificate has already been issued for this pair this endpoint returns **200 OK** with the existing certificate — matching the POST /v1/enrollments convention and the in-app `generate-certificate` edge function's behaviour.","security":[{"ApiKeyAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCertificateBody"}}}},"responses":{"200":{"description":"The existing certificate (one had already been issued for this user+course).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Certificate"}}}},"201":{"description":"The newly issued certificate.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Certificate"}}}},"400":{"description":"Invalid body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Course not found in this community, or user is not a member of this community","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/certificates/{id}":{"get":{"tags":["Certificates"],"summary":"Get a single certificate by id","description":"Returns the `certificates` row identified by `id`. Returns 404 if the row does not exist or belongs to a different community than the API key's. The response shape matches a single element of `GET /v1/certificates` — joined user profile, joined course, PII-stripped.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `certificates.id` (UUID).","example":"cccc3333-dddd-4444-eeee-555555555555"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"The certificate","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Certificate"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Certificate not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"patch":{"tags":["Certificates"],"summary":"Update certificate status (revoke / un-revoke / expire)","description":"Updates a certificate's lifecycle status. `status` is the only mutable field — snapshot fields like `recipient_name`, `course_title`, `completion_date`, and `final_score` are immutable by design so a certificate remains a faithful statement about the moment of issue. Use this endpoint to `'revoked'` a credential after fraud/grading issues, or to manually mark one `'expired'` before its `expires_at` timestamp. Returns 404 if the certificate does not exist or belongs to a different community than the API key's.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `certificates.id` (UUID).","example":"cccc3333-dddd-4444-eeee-555555555555"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCertificateBody"}}}},"responses":{"200":{"description":"The updated certificate.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Certificate"}}}},"400":{"description":"Invalid body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Certificate not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/certificates/verify/{code}":{"get":{"tags":["Certificates"],"summary":"Verify a certificate by its public code","description":"Looks up a certificate by its `certificate_code` (the short human-readable identifier used in public verification URLs) and returns whether it is currently valid. \n\nA certificate is `valid` when it exists in the caller's community, has not been revoked, and has either no expiry or an expiry timestamp in the future. \n\n**Note on auth:** despite the name, this endpoint still requires an API key with the `read:courses` scope — the word *public* in \"public verification\" refers to the certificate code being safe to share on the recipient's social profile, not to the endpoint being unauthenticated. \n\n**Note on 404 behaviour:** when the code is unknown or belongs to another community we return `{ valid: false }` with HTTP **200**, not a 404. This prevents leaking existence information across community boundaries.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"maxLength":64,"description":"The `certificate_code` — the short human-readable identifier embedded in public verification URLs. Format: `CERT-XXXXXX-YYYY`.","example":"CERT-A1B2C3-2026"},"required":true,"name":"code","in":"path"}],"responses":{"200":{"description":"The certificate's validity. `valid: false` here means either the code is unknown, it belongs to another community, the certificate was revoked, or it has expired.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifyCertificateResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/resources":{"get":{"tags":["Resources"],"summary":"List resources in the community associated with the API key","description":"Returns a paginated list of published resources belonging to the community that owns the API key, ordered by `created_at` descending. Only resources where `is_published = true` are returned. Each row is enriched with a minimal `author` object. Filter with `folder_id`, `resource_type`, or `access_level`. The internal R2 object key is never exposed.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"integer","minimum":1,"maximum":100,"default":50,"description":"Max results per page (1–100, default 50).","example":50},"required":false,"name":"limit","in":"query"},{"schema":{"type":["integer","null"],"minimum":0,"default":0,"description":"Number of rows to skip for pagination.","example":0},"required":false,"name":"offset","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Filter to a single resource folder.","example":"cccc3333-dddd-4444-eeee-555555555555"},"required":false,"name":"folder_id","in":"query"},{"schema":{"type":"string","enum":["guide","toolkit","course","checklist","template","other"],"description":"Filter by resource type.","example":"guide"},"required":false,"name":"resource_type","in":"query"},{"schema":{"type":"string","enum":["public","members","premium"],"description":"Filter by access level.","example":"public"},"required":false,"name":"access_level","in":"query"}],"responses":{"200":{"description":"Paginated list of resources","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResourceListResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"post":{"tags":["Resources"],"summary":"Create a new resource in the community","description":"Creates a resource attributed to the user who owns the API key (stored in `api_keys.created_by`). The resource is scoped to the API key's community. Supply either `resource_url` (external link) or `file_url` (uploaded file).","security":[{"ApiKeyAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateResourceBody"}}}},"responses":{"201":{"description":"The newly created resource, with joined author profile","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Resource"}}}},"400":{"description":"Invalid body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/resources/{id}":{"get":{"tags":["Resources"],"summary":"Get a single resource by id","description":"Returns the resource identified by `id`. Returns 404 if the resource does not exist, is unpublished, or belongs to a different community than the API key's.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `resources.id` (UUID).","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"The resource","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Resource"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"patch":{"tags":["Resources"],"summary":"Update a resource","description":"Partially updates a resource. Only fields present in the body are changed. Returns 404 if the resource does not exist or belongs to a different community.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `resources.id` (UUID).","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateResourceBody"}}}},"responses":{"200":{"description":"The updated resource","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Resource"}}}},"400":{"description":"Invalid body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Resources"],"summary":"Delete a resource","description":"Permanently deletes a resource. Returns 204 on success and 404 if the resource does not exist or belongs to a different community. Note: the underlying storage file (if any) is not removed by this endpoint.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `resources.id` (UUID).","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"Deleted (no body)"},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Resource not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/leaderboard":{"get":{"tags":["Leaderboard"],"summary":"Ranked member leaderboard for the community","description":"Returns members of the API key's community ranked by points. Use `period` to choose the all-time, trailing-30-day, or trailing-7-day window. Each entry carries its `rank` and the `points` value for the selected period.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","enum":["all_time","30_day","7_day"],"default":"all_time","description":"Ranking window: all-time total, trailing 30 days, or trailing 7 days.","example":"all_time"},"required":false,"name":"period","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100,"default":50,"description":"Max results per page (1–100, default 50).","example":50},"required":false,"name":"limit","in":"query"},{"schema":{"type":["integer","null"],"minimum":0,"default":0,"description":"Number of rows to skip for pagination.","example":0},"required":false,"name":"offset","in":"query"}],"responses":{"200":{"description":"Ranked leaderboard page","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LeaderboardResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/leaderboard/points/{userId}":{"get":{"tags":["Leaderboard"],"summary":"Get a member's full points breakdown","description":"Returns the points ledger summary for a single member (by auth user id) in the API key's community, broken down by category (posts, comments, events, courses, engagement) plus level and period totals. Returns 404 if the member has no points record in this community.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The auth user id (`community_members.user_id` / `user_points.user_id`).","example":"11111111-2222-3333-4444-555555555555"},"required":true,"name":"userId","in":"path"}],"responses":{"200":{"description":"The member's points breakdown","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PointsBreakdown"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"No points record for this member","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/tracks":{"get":{"tags":["Learning Tracks"],"summary":"List learning tracks in the community","description":"Returns a paginated list of published learning tracks in the API key's community, ordered by `created_at` descending.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"integer","minimum":1,"maximum":100,"default":50,"example":50},"required":false,"name":"limit","in":"query"},{"schema":{"type":["integer","null"],"minimum":0,"default":0,"example":0},"required":false,"name":"offset","in":"query"}],"responses":{"200":{"description":"Paginated list of tracks","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TrackListResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden — API key lacks required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"post":{"tags":["Learning Tracks"],"summary":"Create a learning track","description":"Creates a track attributed to the API key owner, scoped to its community.","security":[{"ApiKeyAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTrackBody"}}}},"responses":{"201":{"description":"The created track","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Track"}}}},"400":{"description":"Invalid body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/tracks/{id}":{"get":{"tags":["Learning Tracks"],"summary":"Get a single learning track by id","description":"Returns the track identified by `id`. 404 if unpublished or cross-community.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `learning_tracks.id` (UUID).","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"The track","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Track"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Track not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"patch":{"tags":["Learning Tracks"],"summary":"Update a learning track","description":"Partially updates a track. 404 if it does not exist or is cross-community.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `learning_tracks.id` (UUID).","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateTrackBody"}}}},"responses":{"200":{"description":"The updated track","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Track"}}}},"400":{"description":"Invalid body","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Track not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"delete":{"tags":["Learning Tracks"],"summary":"Delete a learning track","description":"Deletes a track (its track_courses and progress rows cascade). 204 on success.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The `learning_tracks.id` (UUID).","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"Deleted (no body)"},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Track not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/tracks/{trackId}/courses":{"get":{"tags":["Learning Tracks"],"summary":"List the courses in a track (its curriculum)","description":"Returns the ordered list of courses that make up the track, including each course's position, whether it's required, and any prerequisite.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The parent `learning_tracks.id` (UUID).","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"trackId","in":"path"}],"responses":{"200":{"description":"Ordered track curriculum","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TrackCourseListResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Track not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}},"post":{"tags":["Learning Tracks"],"summary":"Add a course to a track","description":"Adds a course to the track at the given `sort_order`. Both the track and the course must belong to the API key's community.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The parent `learning_tracks.id` (UUID).","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"trackId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddTrackCourseBody"}}}},"responses":{"201":{"description":"The created track-course link","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TrackCourse"}}}},"400":{"description":"Invalid body or course not in community","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Track not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"409":{"description":"Course already in the track, or sort_order already taken","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/tracks/{trackId}/progress":{"get":{"tags":["Learning Tracks"],"summary":"List member progress through a track","description":"Returns per-member progress records for the track (courses completed, percent complete, timestamps) — the track-level analogue of the course gradebook.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The parent `learning_tracks.id` (UUID).","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"trackId","in":"path"},{"schema":{"type":"integer","minimum":1,"maximum":100,"default":50,"example":50},"required":false,"name":"limit","in":"query"},{"schema":{"type":["integer","null"],"minimum":0,"default":0,"example":0},"required":false,"name":"offset","in":"query"}],"responses":{"200":{"description":"Paginated member progress","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TrackProgressListResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Track not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}},"/api/v1/tracks/{trackId}/courses/{courseId}":{"delete":{"tags":["Learning Tracks"],"summary":"Remove a course from a track","description":"Removes the course from the track. 204 on success, 404 if not linked.","security":[{"ApiKeyAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","example":"aaaa1111-bbbb-2222-cccc-333333333333"},"required":true,"name":"trackId","in":"path"},{"schema":{"type":"string","format":"uuid","description":"The `courses.id` to remove from the track.","example":"eeee5555-ffff-6666-aaaa-777777777777"},"required":true,"name":"courseId","in":"path"}],"responses":{"204":{"description":"Removed (no body)"},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"403":{"description":"Forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}},"404":{"description":"Track-course link not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Problem"}}}}}}}},"webhooks":{}}