Course API
Endpoints for the course system (Phase 6). Covers a mentor authoring a course (modules → lessons), publishing and pricing it; the public discovering and previewing published courses; a mentee enrolling and learning with progress tracking; reviews and the storefront; and the management rail — students, coursework, notices, materials, notes and the course discussion.
- Base URL:
/api/v1 - Auth: mentor and mentee endpoints require a Sanctum bearer token —
Authorization: Bearer {token}. Public discovery endpoints need no auth. - Content type:
application/json(file uploads usemultipart/form-data)
Scope note. Payment settlement is live: a paid enrollment is recorded as
pending_payment, Enroll returns acheckout_url, and access begins only when the payment settles (CourseEnrollmentSettler). Community linkage is live too — community-only serving, level-gated modules (unlock_at_level), and community/specific-community pricing all resolve, see Community linkage. Still deferred: premium-exclusive courses (is_exclusive_to_premium, its column exists but is not yet enforced) and enrollment expiry (theexpiredstatus exists so clients can filter on it, but nothing sets it).
⚠️ Breaking changes — the course-system contract
Delivered against the client's three-phase course contract. Everything below is live; this document is the reference for what exists today.
| Endpoint | Change |
|---|---|
GET /mentor/courses |
Now paginated. Was a bare array — read data.courses.data, with data.courses.meta carrying current_page / last_page / total |
GET /public/courses/{slug}, GET /public/courses |
is_enrolled / is_wishlisted were omitted for guests; all four viewer flags are now always present, false for a guest. Any client keying off their presence rather than their value needs updating |
| Every course payload | Added awaits_payment — the abandoned-checkout state, so the resume-payment button is reachable from the course resource alone |
POST /mentee/courses/{course}/enroll |
A paid enrollment is now pending_payment and returns checkout_url; access begins when payment settles rather than immediately |
GET /public/courses |
Unsupported filter values are now a 422 rather than silently ignored |
GET /mentor/courses/{course} (and every sub-resource) |
{course} now resolves a slug or an id |
New surfaces: course filters, reviews, authors, and the six management rail screens.
⚠️ Breaking changes — multi-community courses
A course is no longer limited to one community. courses.community_id has been dropped in
favour of a community_course pivot, so a course can be carried by any number of communities
and priced independently in each. Full mechanics in Community linkage.
Response changes
| Endpoint | Change |
|---|---|
POST/GET/PUT /mentor/courses… |
Removed data.course.community_id. Added data.course.community_ids (array of uuids, [] when unlinked) |
GET /mentee/communities/{community}/courses |
Removed data.courses[].community_id — the endpoint is already scoped to one community, and my_pricing is now resolved in that community's context |
POST /mentee/courses/{course}/enroll |
Added data.enrollment.community_id — the community the access came through (null for a public enrollment) |
{
"id": "9d1c…",
"mentor_id": "8b2a…",
- "community_id": "7f3e…",
+ "community_ids": ["7f3e…", "5a9b…"],
"title": "From Junior to Senior Engineer",
"visibility": "community_only"
}
Request changes
| Endpoint | Change |
|---|---|
POST /mentor/courses, PUT /mentor/courses/{course} |
Added community_ids (array of uuids, each a community you own). community_id is still accepted as a deprecated alias and folded into the set |
POST /mentee/courses/{course}/enroll |
Added optional community_id — enroll through a specific community. Omit it and the carrying community where you hold the highest level is chosen |
POST/PUT …/modules |
unlock_at_level is now rejected unless a community carries the course (422) |
// Create a course carried by two communities
{
"title": "From Junior to Senior Engineer",
"visibility": "community_only",
"community_ids": ["7f3e…", "5a9b…"]
}
Update semantics: omit community_ids to leave the links untouched; send an explicit array
to replace the whole set. "community_ids": [] (or the deprecated "community_id": null)
unlinks the course — and is rejected with 422 if its visibility is community_only.
Migration for clients: replace reads of community_id with community_ids[0] where a single
value is still assumed, and switch writes to community_ids. Validation errors are reported
against whichever key you sent — community_id or community_ids.{index}.
Response envelope
Every response uses the standard envelope.
Success
{ "status": "success", "message": "…", "data": { } }
Error
{ "status": "error", "message": "…", "data": { } }
Validation errors (422) return field errors in data:
{
"status": "error",
"message": "The title field is required.",
"data": { "title": ["The title field is required."] }
}
Status codes
| Code | Meaning |
|---|---|
| 200 | OK (read, update, lifecycle action) |
| 201 | Resource created |
| 401 | Missing/invalid token |
| 403 | Authenticated but not entitled (e.g. content access without enrollment) |
| 404 | Not found or not owned by the caller |
| 422 | Validation error / invalid state (e.g. enroll in a draft, quiz on a non-quiz lesson, quiz time limit exceeded, late assignment without a note) |
| 500 | Server error |
Enums
Course status: draft · published · archived
Course visibility
| Value | Public search | Direct slug | Notes |
|---|---|---|---|
public |
✅ | ✅ | Standard public course |
unlisted |
❌ | ✅ | Share by link only |
community_only |
❌ | ❌ | Members-only — gated to the carrying communities (see Community linkage) |
Course level: beginner · intermediate · advanced · all
(defaults to all, which renders as "All levels" — never null)
Course category (slug): development · business · design · marketing · finance ·
it-and-software · personal-development · photography · music · health-and-fitness ·
teaching · lifestyle · other
Course language (code): english · spanish · french · german · portuguese ·
italian · arabic · hindi · bengali · urdu · chinese · japanese · korean ·
russian · turkish · indonesian · other
Category, language and level are fixed vocabularies, served by
GET /public/course-filters. A value outside them is a 422, not a silent ignore — an author can never save a category the server does not recognise.
Lesson type: video · text · pdf · quiz · assignment
(quiz auto-scores; assignment is a mentor-graded file submission)
Course author role: owner · co_author · assistant
(owner is derived from courses.mentor_id and can never be assigned — see §32)
Course material type: video · image · file · link
Submission type: assignment · quiz
(not a stored column — both are course_lesson_progress rows, and the type is the parent
lesson's lesson_type)
Pricing audience: public · community_member · specific_community
(all three resolve — within a community's context, a member's community price wins over the
public price; see Community linkage)
Enrollment status: pending_payment · active · completed · refunded · cancelled · expired
(only active and completed grant content access. pending_payment holds the learner's place
in the unique(user_id, course_id) slot so an abandoned checkout can be retried, and grants
nothing. expired exists so the students screen can filter on it; nothing sets it yet.)
Lesson progress status: in_progress · submitted · completed
(submitted = an assignment awaiting the mentor's grade)
Shared objects
Course object (mentor / authoring view)
{
"id": "9b6f…",
"mentor_id": "8a1c…",
"community_ids": [],
"title": "Mastering Product Management",
"slug": "mastering-product-management",
"subtitle": "From backlog to roadmap",
"description": "…",
"summary": "A concise overview of the course.",
"learning_outcomes": ["Prioritise a backlog", "Build a roadmap", "Run discovery"],
"requirements": ["A laptop", "Basic spreadsheet literacy"],
"includes": [
{ "icon": "video", "label": "4 hours of on-demand video" },
{ "icon": "article", "label": "12 lessons" },
{ "icon": "mobile", "label": "Access on mobile and desktop" },
{ "icon": "lifetime", "label": "Full lifetime access" }
],
"includes_is_custom": false,
"category": "business",
"category_label": "Business",
"language": "english",
"language_label": "English",
"level": "intermediate",
"level_label": "Intermediate",
"has_certificate": false,
"downloadable_resources_count": 0,
"status": "draft",
"status_label": "Draft",
"visibility": "public",
"visibility_label": "Public",
"is_free": false,
"price": "49.00",
"currency": "USD",
"is_exclusive_to_premium": false,
"total_lessons": 12,
"total_duration_minutes": 240,
"enrollment_count": 0,
"average_rating": "4.60",
"rating_count": 5,
"rating_breakdown": { "5": 3, "4": 2, "3": 0, "2": 0, "1": 0 },
"thumbnail_url": null,
"mentor": { "…mentor block…" },
"is_enrolled": false,
"is_wishlisted": false,
"is_creator": true,
"awaits_payment": false,
"meta_title": null,
"meta_description": null,
"og_image_url": null,
"faqs": [],
"cta_label": null,
"modules": [ { "…module object…" } ],
"published_at": null,
"created_at": "2026-07-02T09:00:00+00:00",
"updated_at": "2026-07-02T09:00:00+00:00"
}
modules(with nestedlessons) is included only on the show response.total_lessons/total_duration_minutesare recomputed from published lessons.
Field notes
| Field | Notes |
|---|---|
requirements |
Mirrors learning_outcomes exactly — same storage, same validation, always an array, never null |
includes |
The "This course includes" rows. Always populated: served verbatim when the author wrote their own, otherwise derived from total_duration_minutes / total_lessons / downloadable_resources_count / has_certificate. icon is one of video · article · download · quiz · assignment · certificate · lifetime · mobile |
includes_is_custom |
bool. true when the rows are the author's own, false when derived. Present on both the mentor and public resources — it is what decides whether to show "Reset to automatic", since there is nothing to reset on a derived list |
level |
Never null — defaults to all |
rating_breakdown |
A JSON object keyed "5"…"1", all five keys present, zeros included. Recomputed on every review write. See the warning below |
is_enrolled / is_wishlisted / is_creator / awaits_payment |
Always present, never omitted — a guest gets four falses. See Viewer flags |
og_image_url |
Falls back to thumbnail_url when unset, so the share card is never blank |
faqs |
{ question, answer }[]. Always an array, never null |
cta_label |
Overrides the enrol button's label. There is deliberately no cta_url — see Storefront |
rating_breakdown is an object
{"5":3,"4":2,"3":0,"2":0,"1":0} — not an array. Worth stating because it nearly shipped
wrong: Laravel's JsonResource::filter() re-indexes any array whose keys are all numeric, and
"5"…"1" are all numeric, so left as a PHP array this serialized as [3,2,0,0,0] — a list, in
an order a client would have had to guess. It is cast to an object specifically to stay out of
that code path. An array here is a bug, not a variant.
Viewer flags
Four flags appear on every course payload: browse (list), show by slug, my courses, the wishlist, and the nested course on my enrollments.
| Flag | True when |
|---|---|
is_enrolled |
The caller has an active or completed enrollment |
is_wishlisted |
The caller has saved the course |
is_creator |
The caller's mentor profile owns the course |
awaits_payment |
The caller has a pending_payment enrollment — an abandoned checkout |
They are always present. A guest gets false four times rather than four missing keys — an
absent flag is indistinguishable from false right up until a client writes !== false. All four
cost two queries per page, not two per row; is_enrolled and awaits_payment come from the
same enrollment query, because they are two answers about one row.
awaits_payment — the resume-payment state
An abandoned checkout leaves a real enrollment behind that grants no access, so
is_enrolled is correctly false for that learner. Without a second flag they would be
indistinguishable, on the course payload alone, from someone who never started — and the
enrol button would offer to enrol somebody who is already half-enrolled.
awaits_payment: true is the cue to render the resume state ("CONTINUE TO PAYMENT · $49"
rather than "ENROL · $49"). Re-posting to /enroll resumes the existing row with a
refreshed price snapshot rather than colliding with it, so the button can point straight back at
the same endpoint.
The two flags are never both true. exclude_enrolled agrees with them: a pending_payment course
stays in Discover, because it is still something to buy.
The mentor block
"mentor": {
"id": "8a1c…",
"user_id": "4f2b…",
"username": "janedoe",
"name": "Jane Doe",
"headline": "Product leader",
"avatar_url": null,
"bio": "…",
"average_rating": "4.72",
"reviews_count": 41,
"students_count": 380,
"courses_count": 6
}
id is the mentor profile id — compare it against user.mentor_profile.id to decide
ownership — while username addresses the user and is what /user/{username} is built from.
Keep the two apart; the same distinction applies to communities.
⚠️ The bottom five appear on a single-course read only — show by slug and mentor show — and are absent from every list. A discover page renders no instructor card, and four aggregate queries per row would be an N+1.
⚠️ mentor.average_rating is the mentor's course rating across their whole catalogue, over
reviews_count course reviews. It is distinct from the course's own average_rating sitting next
to it, and it is not their 1:1 session rating, which measures a different thing.
Module object
{
"id": "1a2b…",
"course_id": "9b6f…",
"title": "Foundations",
"description": null,
"position": 0,
"unlock_at_level": null,
"is_locked": false,
"is_published": true,
"lessons_count": 4,
"total_duration_minutes": 48,
"lessons": [ { "…lesson object…" } ],
"created_at": "…",
"updated_at": "…"
}
lessons_countandtotal_duration_minutesare derived from the loaded lessons (or from awithCount, when the caller shaped the query for it) and never fire their own query — an unshaped read reports0rather than an N+1. They were added so the mentor and public module payloads agree field-for-field.
Lesson object (mentor / authoring view)
{
"id": "3c4d…",
"module_id": "1a2b…",
"course_id": "9b6f…",
"title": "What is a roadmap?",
"lesson_type": "video",
"lesson_type_label": "Video",
"position": 0,
"text_content": null,
"video_provider": "youtube",
"video_url": "https://www.youtube.com/watch?v=…",
"duration_minutes": 12,
"time_limit_minutes": null,
"quiz_payload": { "pass_pct": 70, "questions": [ { "prompt": "…", "type": "single", "options": ["…"], "correct": [1], "explanation": "…" } ] },
"assignment_payload": null,
"is_preview": false,
"is_published": true,
"locked": false,
"attachment_url": null,
"assignment_attachments": [],
"created_at": "…",
"updated_at": "…"
}
lockedis alwaysfalsehere: an author is never locked out of their own lesson. It is present so a client reads one lesson shape on both the authoring and public payloads.
time_limit_minutes(optional) applies toquizandassignmentlessons — see the timer notes.assignment_payloadholds{ total_marks, passing_marks, instructions };assignment_attachmentsis the array of mentor-provided brief files ([{ id, name, url }]).
Pricing object (a layer-1 rate)
{
"id": "7d2e…",
"course_id": "9b6f…",
"audience_type": "specific_community",
"audience_type_label": "Specific Community",
"is_deprecated": false,
"label": "React Devs BD members",
"community_id": "7f3e…",
"community": { "id": "7f3e…", "name": "React Devs BD", "slug": "react-devs-bd" },
"is_free": false,
"price": "79.00",
"currency": "USD",
"starts_at": null,
"ends_at": "2026-09-30T23:59:59+00:00",
"is_active": true,
"is_live": true,
"priority": 0,
"created_at": "…",
"updated_at": "…"
}
is_activeis the switch;is_liveis the answer — active and inside its window right now. A scheduled rate isis_active: true,is_live: false.is_deprecatedmarks a row whose audience can no longer be created (§17); it stays editable and deletable.
my_pricing
On every course payload, always present, never null. A course with no rules resolves to its
own price with rate: null — that is a real answer, not a missing one, so no client has to
decide which price field beats which.
{
"base_price": "99.00",
"list_price": "79.00",
"price": "63.20",
"currency": "USD",
"is_free": false,
"audience_type": "specific_community",
"audience_type_label": "Specific Community",
"rate": { "rule_id": "7d2e…", "audience_type": "specific_community", "audience_type_label": "Specific Community", "label": "React Devs BD members", "community_id": "7f3e…", "community": { "id": "7f3e…", "name": "React Devs BD", "slug": "react-devs-bd" }, "starts_at": null, "ends_at": null },
"offer": { "id": "0f1a…", "label": "Launch week", "discount_type": "percent", "discount_value": "20.00", "currency": null, "starts_at": null, "ends_at": "2026-09-01T23:59:59+00:00", "allows_coupons": true, "status": "live", "status_label": "Live" },
"savings": "15.80",
"savings_pct": 20
}
| Field | Means |
|---|---|
base_price |
The course's own price. Context only — never the strikethrough |
list_price |
After layer 1: what this viewer pays with no offer running. This is the strikethrough |
price |
After layer 2: what they pay right now |
savings |
list_price − price |
Layer 3 never appears here — a coupon is quoted on demand, on
§21. The flat is_free / price / currency beside
this block are the course's own list price and are legacy: new code should read my_pricing.
Enrollment object
{
"id": "5e6f…",
"course_id": "9b6f…",
"community_id": "7f3e…",
"status": "active",
"status_label": "Active",
"audience_type": "public",
"is_free": false,
"price_snapshot": "49.00",
"list_price_snapshot": "99.00",
"offer_id": "0f1a…",
"coupon_id": "3c4d…",
"discount_amount": "50.00",
"currency": "USD",
"awaits_payment": false,
"progress_pct": 50,
"completed_lessons_count": 6,
"enrolled_at": "2026-07-02T09:00:00+00:00",
"completed_at": null,
"last_accessed_at": "2026-07-02T10:00:00+00:00",
"course": { "…public course object (when loaded)…" }
}
awaits_paymenttells "enrolled" from "still owes money" without inferring it from the status string. It istrueonly forpending_payment.
price_snapshotis what is owed;list_price_snapshot,offer_id,coupon_idanddiscount_amountare why it is that number, so "you saved $15.80 with LAUNCH20" costs no second lookup on either the learner's enrollment or the author's sales list. They survive the offer or code being deleted afterwards.
Mentor endpoints — authoring
All require an approved mentor profile. Ownership is enforced by scoping: another mentor's course (or a missing mentor profile) returns 404 — never 403, which would confirm the course exists to someone with no claim to it.
{course} takes a slug or an id
Every mentor course route below, and every sub-resource under it — /publish, /unpublish,
/archive, /modules, /pricing, /students, /submissions, /notices, /materials,
/authors — resolves {course} as either the course UUID or its public slug.
The course page is addressed by slug, because that is what public links use. Bridging that
client-side meant fetching the mentor's entire catalogue on every page load purely to map
slug → id; accepting both here deletes that request.
1. List my courses
GET /api/v1/mentor/courses
Returns the mentor's courses (all statuses), newest first. Paginated.
| Param | Type | Notes |
|---|---|---|
search |
string | Title / subtitle / summary / description / mentor name |
status |
enum | draft · published · archived |
sort |
enum | newest (default) · popular · rating · price_low · price_high · title |
page |
int | |
per_page |
int | 1–100 (default 15) |
200 → { "data": { "courses": { "data": [ … ], "links": { … }, "meta": { … } } } }
⚠️ Breaking. This returned a bare array until the course-system contract landed. Read
data.courses.data;data.courses.metacarriescurrent_page/last_page/total.
Every row carries the four viewer flags. is_creator is trivially true here,
and saying so beats making the client infer it.
Self-only. This lists the caller's own courses. To read another user's courses — or to render a profile page — use
GET /api/v1/user/{username}/courses(Authentication API, endpoint 42). That one is paginated, merges authored with enrolled courses behindis_creator/is_enrolledflags, and hides drafts and archived courses from everyone but the author.
2. Create a course
POST /api/v1/mentor/courses (multipart/form-data if sending a thumbnail)
Creates a draft course and auto-generates a unique slug from the title.
| Field | Type | Required | Notes |
|---|---|---|---|
title |
string | yes | Max 255 |
subtitle |
string | no | Max 255 |
description |
string | no | |
summary |
string | no | Short overview |
learning_outcomes |
string[] | no | Array of outcomes; each ≤ 255 |
requirements |
string[] | no | Same shape and rules as learning_outcomes |
includes |
object[] | no | [{ icon, label }]. Omit and the list is derived from the counters |
category |
enum | no | Slug — see Enums |
language |
enum | no | Code — see Enums |
level |
enum | no | Defaults to all |
has_certificate |
bool | no | Default false |
downloadable_resources_count |
int | no | Default 0 |
meta_title |
string | no | ≤ 255. Storefront — <title> and share-card headline |
meta_description |
string | no | ≤ 500. Search snippet and share-card body |
og_image |
file | no | Image ≤ 5 MB. Returns og_image_url |
faqs |
object[] | no | [{ question, answer }] |
cta_label |
string | no | ≤ 100. Overrides the enrol button label |
community_ids |
uuid[] | conditionally | Communities carrying the course — each must be one you own. At least one required when visibility = community_only |
community_id |
uuid | no | Deprecated — single-community alias, folded into community_ids |
visibility |
enum | no | public (default) · unlisted · community_only |
is_free |
bool | no | Default false |
price |
decimal | conditionally | Required unless is_free is true |
currency |
— | ignored | Derived from the mentor's profile currency. Not accepted; a value sent here is stripped by validation. |
is_exclusive_to_premium |
bool | no | Stored; not yet enforced |
thumbnail |
file | no | Image, ≤ 5 MB |
201 → { "data": { "course": { "…course object…" } } }
Errors: 422 missing title / missing price when not free / a community_ids entry not owned
by you / community_only with no communities · 404 no mentor profile
3. Show a course
GET /api/v1/mentor/courses/{course} ← slug or id
Returns the course with its modules → lessons eager-loaded (all statuses; authoring view), plus the pricing rows and the full mentor block including instructor-card stats.
200 → { "data": { "course": { "…course object with modules…" } } }
Errors: 404 not found / not owned
4. Update a course
PUT|PATCH /api/v1/mentor/courses/{course} ← slug or id
(multipart/form-data if sending a file)
Same fields as create, all optional (sometimes). Changing title regenerates the slug.
Send as multipart/form-data with _method=put when attaching a thumbnail or og_image —
PHP cannot read files from a real PUT body.
visibility, is_free, price and currency are all accepted here, not only on create.
Empty lists mean "delete every row"
learning_outcomes, requirements, faqs and includes all follow one rule:
| What you send | What happens |
|---|---|
| key absent | the stored list is left untouched |
requirements[]="" (only empty strings) |
the list is cleared |
requirements[]=A&requirements[]= |
collapses to ["A"] |
An HTML form cannot send an absent array, so a list containing only empty strings is how the client says "the author deleted every row". Without this the old rows survive the deletion.
includesandfaqsare lists of objects, so they are written asincludes[0][icon]/includes[0][label]— the same two-level shapefaqsuses — whilelearning_outcomes,requirementsandtagsare flat string lists. The clear signal is the same[]=""for all five.
Pass community_ids to replace the whole set of communities carrying the course; omit the key to
leave the links untouched, or send [] to unlink it entirely. The community-only invariant is
checked against the resulting state: switching visibility to community_only on a course no
community carries (and none supplied) returns 422, as does clearing the last community of a
course that is already community_only.
200 → { "data": { "course": { … } } }
Errors: 422 a community_ids entry not owned by you / community_only with no communities
5–7. Lifecycle: publish / unpublish / archive
PATCH /api/v1/mentor/courses/{course}/publish → status: published (sets published_at once)
PATCH /api/v1/mentor/courses/{course}/unpublish → status: draft
PATCH /api/v1/mentor/courses/{course}/archive → status: archived
An archived course behaves like an unpublished one for everyone but its owner: it drops out
of public browse and slug lookup, since both filter on published.
200 → { "data": { "course": { … } } }
8. Delete a course
DELETE /api/v1/mentor/courses/{course}
Cascades to modules, lessons, pricing, enrollments, progress, reviews, authors, notices, materials, notes, and comments.
200 → { "message": "Course deleted successfully." }
Mentor endpoints — modules
Nested under a course the mentor owns. A course not owned by the caller → 404.
9. List / create modules
GET /api/v1/mentor/courses/{course}/modules → { "data": { "modules": [ … with lessons ] } }
POST /api/v1/mentor/courses/{course}/modules → 201 { "data": { "module": { … } } }
Create payload
| Field | Type | Required | Notes |
|---|---|---|---|
title |
string | yes | Max 255 |
description |
string | no | |
position |
int | no | Auto = max(position) + 1 when omitted |
unlock_at_level |
int | no | ≥ 0; locks the module until the learner reaches this level in the community they enrolled through. 0 is the ladder floor — open to every member, still shut to non-members. Rejected (422) unless a community carries the course (see Community linkage) |
is_published |
bool | no | Default true |
10. Update / delete a module
PUT|PATCH /api/v1/mentor/courses/{course}/modules/{id} → { "data": { "module": { … } } }
DELETE /api/v1/mentor/courses/{course}/modules/{id} → { "message": "Module deleted successfully." }
11. Reorder modules
PATCH /api/v1/mentor/courses/{course}/modules/reorder
Sets position to each id's index in the array (0-based). Ids not belonging to the course are
ignored.
{ "ordered_ids": ["moduleB", "moduleA", "moduleC"] }
200 → { "data": { "modules": [ … in new order ] } }
Errors: 422 empty / non-uuid ordered_ids
Mentor endpoints — lessons
Nested under a module. Creating or deleting a lesson recomputes the course's
total_lessons / total_duration_minutes (from published lessons).
12. List / show / create lessons
GET /api/v1/mentor/courses/{course}/modules/{module}/lessons → { "data": { "lessons": [ … ] } }
GET /api/v1/mentor/courses/{course}/modules/{module}/lessons/{id} → { "data": { "lesson": { … } } }
POST /api/v1/mentor/courses/{course}/modules/{module}/lessons → 201 { "data": { "lesson": { … } } }
Create payload (multipart/form-data when uploading files)
| Field | Type | Required | Notes |
|---|---|---|---|
title |
string | yes | Max 255 |
lesson_type |
enum | yes | video · text · pdf · quiz · assignment |
position |
int | no | Auto-positioned when omitted |
text_content |
string | for text |
Article body |
video_provider |
string | no | e.g. youtube |
video_url |
url | for video |
Max 2048 |
duration_minutes |
int | no | Feeds the course total |
time_limit_minutes |
int | no | ≥ 1; timer for quiz / assignment (see Timer) |
quiz_payload |
object | for quiz |
See below |
assignment_payload |
object | for assignment |
See below |
attachment |
file | for pdf |
mimes:pdf, ≤ 10 MB |
assignment_attachments |
file[] | no | Brief files for assignment; pdf,doc,docx,ppt,pptx,xls,xlsx,zip,png,jpg,jpeg, ≤ 10 MB each |
is_preview |
bool | no | Free teaser (shown to non-enrolled) |
is_published |
bool | no | Default true |
Content requirement by type (enforced): video → video_url; text → text_content;
pdf → attachment; quiz → quiz_payload; assignment → assignment_payload.
Quiz payload shape
{
"pass_pct": 70,
"questions": [
{ "prompt": "2 + 2?", "type": "single", "options": ["3", "4", "5"], "correct": [1], "explanation": "…" }
]
}
correct is an array of the correct option indices (supports multi-select).
type is single or multiple — how the question is answered, and therefore whether the
learner is drawn radios or checkboxes. Anything else is a 422.
Omit it and it is filled in from the answer key: two or more correct indices means
multiple, otherwisesingle. So it is optional on the wire and always present in storage. It is the author's to choose, though — a single-answer question with one right option out of five is still legitimatelymultipleif that is what you send.
Assignment payload shape
{ "total_marks": 100, "passing_marks": 50, "instructions": "Complete the attached brief." }
passing_marks must be ≤ total_marks (validated). Upload the brief files the learner works
from as assignment_attachments[]. The learner uploads their solution via
Submit an assignment; the mentor then grades it (see
assignment submissions & grading).
13. Update / delete a lesson
PUT|PATCH /api/v1/mentor/courses/{course}/modules/{module}/lessons/{id}
DELETE /api/v1/mentor/courses/{course}/modules/{module}/lessons/{id}
Update fields are all optional; on update the course totals are recomputed.
14. Reorder lessons
PATCH /api/v1/mentor/courses/{course}/modules/{module}/lessons/reorder
Same contract as module reorder, scoped to the module.
{ "ordered_ids": ["lessonB", "lessonA"] }
Mentor endpoints — assignment submissions & grading
Nested under an assignment lesson the mentor owns. Learners submit files via endpoint 29; these endpoints let the mentor review and grade them.
Submission object
{
"id": "6f7a…",
"enrollment_id": "5e6f…",
"lesson_id": "3c4d…",
"status": "submitted",
"status_label": "Submitted",
"learner": { "id": "8a1c…", "name": "Sam Lee", "email": "[email protected]" },
"note": "Here is my work.",
"file_url": "https://…/solution.pdf",
"is_late": false,
"submitted_at": "2026-07-03T09:00:00+00:00",
"awarded_marks": null,
"graded_at": null,
"feedback": null
}
15. List submissions
GET /api/v1/mentor/courses/{course}/modules/{module}/lessons/{lesson}/submissions
Returns every learner submission for the assignment lesson, newest first (each with the submitting learner and their uploaded file).
200 → { "data": { "submissions": [ { "…submission object…" } ] } }
Errors: 404 lesson not found / not owned
16. Grade a submission
PATCH /api/v1/mentor/courses/{course}/modules/{module}/lessons/{lesson}/submissions/{submission}/grade
Records the awarded marks. A pass (awarded_marks ≥ passing_marks) marks the lesson
completed and rolls up the enrollment; a fail returns it to in_progress so the learner
may resubmit.
Payload
| Field | Type | Required | Notes |
|---|---|---|---|
awarded_marks |
int | yes | ≥ 0 and ≤ total_marks |
feedback |
string | no | Max 2000 |
200
{
"data": {
"passed": true,
"submission": { "…submission object, now graded…" }
}
}
Errors: 422 awarded_marks exceeds the lesson's total_marks · lesson isn't an
assignment · submission not yet submitted · 404 lesson / submission not found
Mentor endpoints — pricing
Three layers plus a code, each producing at most one winner, applied in order:
base price on the course $99 what everyone pays, signed in or out
↓ a rate may replace it entirely
LAYER 1 · rate $79 "React Devs BD members pay 79" §17–18
↓ an offer discounts whatever won layer 1
LAYER 2 · offer $63.20 "20% off, launch week" §18a
↓ a coupon discounts whatever won layer 2
LAYER 3 · coupon $56.88 the learner types EARLYBIRD §18b
The base price is the price for everyone, not a fallback: a rate is the exception to it.
Every layer is resolved for every viewer on every course payload — see
my_pricing — except the coupon, which is quoted on demand (§21).
Why two audiences were retired.
publicduplicated the base price — a rate for everyone is the base price by another name — andcommunity_membermeant "any community", which priced a course for members of communities that had nothing to do with it. Both are rejected on write; existing rows are served but never win. The tiebreak that replaced them resolves most specific first, so a rate naming this community beats a broader one.
17. List / create pricing (layer 1 — rates)
GET /api/v1/mentor/courses/{course}/pricing → { "data": { "pricing": [ { …rate object… } ] } }
POST /api/v1/mentor/courses/{course}/pricing → 201 { "data": { "pricing": { … } } }
Create payload
| Field | Type | Required | Notes |
|---|---|---|---|
audience_type |
enum | yes | specific_community · any_community_member. See the deprecation note below |
label |
string | no | The author's own name for the rate — "React Devs BD members" |
community_id |
uuid | conditionally | Required for specific_community, forbidden for any_community_member — and must be a community you own |
is_free |
bool | no | |
price |
decimal | conditionally | Required unless is_free is true |
currency |
— | ignored | Derived from the mentor's profile currency. Not accepted; a value sent here is stripped by validation. |
starts_at |
datetime | no | Null = open-ended |
ends_at |
datetime | no | Null = open-ended. Must fall after starts_at |
is_active |
bool | no | Default true. A switched-off rate applies to nobody |
priority |
int | no | Breaks ties between rates that cost the same. Higher wins |
Rate object adds is_deprecated, is_live (active and inside its window right now) and
a community object (id, name, slug) so a four-row list is readable without resolving
community_id client-side.
Two audiences are deprecated
public and community_member are deprecated-but-readable: existing rows keep resolving,
stay editable and stay deletable — deleting one falls back to the base price, which is the
correct migration — but creating a new one is a 422 on audience_type.
publicreplaces the base price for every viewer, which leaves the author's Base price field reading the old number while everyone pays the rate, and under lowest-price-first it silently outranks every narrower rate below it. A windowed price for everyone is an offer (§18a), which does it better: one row, a countdown, and a struck-throughlist_price.community_memberrequires acommunity_id, making it an exact duplicate ofspecific_community.any_community_member— a member of any community carrying the course — is the broad audience it was mistaken for.
Overlap is now an application check
The UNIQUE (course_id, audience_type, community_id) index is gone: it made windowed rates
impossible to express. Two rates for the same audience whose windows overlap while both are
active are a 422 keyed on ends_at (or starts_at for an open-ended rate), so the message
renders under the control that caused it. A paused rate never collides with anything.
Errors: 422 a deprecated audience · a community audience without a community · a
community_id you don't own · an overlapping window.
18. Update / delete pricing
PUT|PATCH /api/v1/mentor/courses/{course}/pricing/{id} → { "data": { "pricing": { … } } }
DELETE /api/v1/mentor/courses/{course}/pricing/{id} → { "message": "Pricing deleted successfully." }
Partial — the list's pause switch sends { "is_active": false } alone. audience_type and
community_id are not editable: re-pointing a live rate at a different audience reprices a
course silently. Delete it and create a new one instead.
18a. Offers (layer 2)
GET /api/v1/mentor/courses/{course}/offers → { "data": { "offers": [ { …offer… } ] } }
POST /api/v1/mentor/courses/{course}/offers → 201 { "data": { "offer": { … } } }
PUT /api/v1/mentor/courses/{course}/offers/{offer} → { "data": { "offer": { … } } }
DELETE /api/v1/mentor/courses/{course}/offers/{offer} → { "message": "Offer deleted successfully." }
An offer discounts whatever rate won layer 1 rather than replacing it, so list_price
survives to be struck through.
| Field | Type | Required | Notes |
|---|---|---|---|
label |
string | yes | Shown to the learner beside the discounted price |
discount_type |
enum | yes | percent · fixed |
discount_value |
decimal | yes | > 0; ≤ 100 for a percentage |
currency |
— | ignored | Derived from the course's currency for fixed, and null for percent. Not accepted. |
starts_at / ends_at |
datetime | no | Null = open-ended. A bare YYYY-MM-DD ends_at means the end of that day |
is_active |
bool | no | Default true |
allows_coupons |
bool | no | Default true. false means the offer is already the floor — a code applied over it is a 422 |
priority |
int | no | Breaks ties between offers worth the same |
status is server-derived — live · scheduled · expired · paused. Offers may overlap
freely: the resolver takes whichever discounts the viewer's rate the most.
18b. Coupons (layer 3)
GET /api/v1/mentor/courses/{course}/coupons → { "data": { "coupons": [ { …coupon… } ] } }
POST /api/v1/mentor/courses/{course}/coupons → 201 { "data": { "coupon": { … } } }
PUT /api/v1/mentor/courses/{course}/coupons/{coupon} → { "data": { "coupon": { … } } }
DELETE /api/v1/mentor/courses/{course}/coupons/{coupon} → { "message": "Coupon deleted successfully." }
A course with no codes is a 200 with "coupons": [] — never a 404.
| Field | Type | Required | Notes |
|---|---|---|---|
code |
string | yes | Letters, numbers, hyphens, underscores. Stored upper-cased; redeem is case-insensitive |
discount_type |
enum | yes | percent · fixed |
discount_value |
decimal | yes | > 0; ≤ 100 for a percentage |
currency |
— | ignored | Derived from the course's currency for fixed, and null for percent. Not accepted. |
max_redemptions |
int | no | Null = unlimited |
per_user_limit |
int | no | Null = unlimited |
starts_at / expires_at |
date | no | YYYY-MM-DD. Read as the start and the end of that day, so a code expiring "on the 30th" works all of the 30th |
is_active |
bool | no | Default true |
status is server-derived — live · scheduled · expired · used_up · paused — so the
author's badge and the redeem path cannot disagree. redemptions_count moves on payment
capture, not on checkout creation.
Deleting a code does not touch enrollments made with it: the enrollment keeps its
coupon_id, list_price_snapshot and discount_amount as the record of what was charged.
Errors: 422 duplicate code on this course (keyed on code) · validation.
Public endpoints — discovery
No authentication required.
19. Browse courses
GET /api/v1/public/courses
Lists published + public courses only (drafts, unlisted, and community_only are
excluded). Paginated.
Optional auth. Works without a token; a bearer token is read when present and populates the four viewer flags on every row.
Query parameters
| Param | Type | Notes |
|---|---|---|
search |
string | Matches title / subtitle / summary / description / the mentor's name and username |
is_free |
bool | Filter by free/paid |
mentor_id |
uuid | Courses by one mentor |
category |
enum | Slug — see Enums |
language |
enum | Code — see Enums |
level |
enum | beginner · intermediate · advanced · all |
sort |
enum | newest (default) · popular (enrolment count) · rating · price_low · price_high |
exclude_enrolled |
bool | Drop courses the caller is enrolled in or owns |
page |
int | |
per_page |
int | 1–100 (default 15) |
An unsupported value is a 422, not a silent ignore.
?level=experterrors rather than quietly returning the unfiltered list — a discarded filter returns the wrong list, which is worse than an error.
exclude_enrolled
Applied in SQL, before pagination, so meta.total and the row count always agree. Filtering
client-side is what let page 1 render 4 cards while meta.total said 12.
- A
pending_paymentenrolment is not excluded — the learner still owes money and has no access, so the course is still something to discover. - Courses the caller owns are excluded too.
- For a guest the filter is a harmless no-op: they own and are enrolled in nothing.
200 Response
{
"status": "success",
"message": "Courses retrieved successfully.",
"data": {
"courses": {
"data": [ { "…public course object…" } ],
"links": { "…" },
"meta": { "current_page": 1, "last_page": 1, "total": 2, "per_page": 15 }
}
}
}
Errors: 422 invalid sort, category, language, level, or exclude_enrolled
19a. Course filter vocabularies
GET /api/v1/public/course-filters
The vocabularies behind the discover selects and the Settings tab, mirroring
/public/community-filters. Served from the enums rather than from a distinct-values query, so an
empty catalogue still offers a full set of choices — and so an author can never pick a category
the server does not recognise.
200
{
"status": "success",
"message": "Course filters retrieved successfully.",
"data": {
"categories": [ { "slug": "development", "name": "Development" } ],
"languages": [ { "code": "english", "name": "English" } ],
"levels": [ { "value": "beginner", "label": "Beginner" } ]
}
}
20. Show a course by slug
GET /api/v1/public/courses/{slug}
Returns a published public or unlisted course with its published modules →
lessons. community_only, draft, and archived courses return 404.
Optional auth. The endpoint works without a token, but a bearer token is read when present
and populates the four viewer flags and per-lesson progress.
⚠️ Breaking.
is_enrolledandis_wishlistedused to be omitted entirely for guests. All three flags are now always present,falsefor a guest. Any client keying off their presence rather than their value needs updating.
Also carries created_at / updated_at alongside published_at, so "Updated {month year}" is
actually last-updated rather than falling back to the publish date.
Public lesson gating: non-is_preview lessons are returned with "locked": true and
their content fields (text_content, video_url, quiz_payload, attachment_url) omitted.
Preview lessons expose their content.
locked stays preview-based even for an enrolled learner — this payload is the sales-page
outline, and the content they are entitled to comes from
Get course content.
Per-lesson progress (enrolled callers only). Each lesson carries
{ status, status_label, completed_at } | null, so an enrolled learner opening the public page
sees their completion ticks instead of a blank outline. One query for the whole curriculum. A
guest — and an enrolled learner who has not touched a lesson — gets null.
200 (excerpt)
{
"data": {
"course": {
"id": "9b6f…", "title": "…", "slug": "…", "is_free": false, "price": "49.00",
"level": "intermediate", "level_label": "Intermediate",
"language": "english", "language_label": "English",
"requirements": ["A laptop"],
"rating_count": 5,
"rating_breakdown": { "5": 3, "4": 2, "3": 0, "2": 0, "1": 0 },
"is_enrolled": true, "is_wishlisted": false, "is_creator": false,
"awaits_payment": false,
"mentor": {
"id": "8a1c…", "user_id": "4f2b…", "username": "janedoe", "name": "Jane Doe",
"headline": "…", "avatar_url": null,
"bio": "…", "average_rating": "4.72", "reviews_count": 41,
"students_count": 380, "courses_count": 6
},
"modules": [
{ "id": "1a2b…", "title": "Foundations", "lessons_count": 2,
"lessons": [
{ "id": "3c4d…", "title": "Preview", "lesson_type": "video", "locked": false,
"is_preview": true, "video_url": "https://…",
"progress": { "status": "completed", "status_label": "Completed",
"completed_at": "2026-08-01T10:00:00+00:00" } },
{ "id": "4d5e…", "title": "Locked", "lesson_type": "video", "locked": true,
"is_preview": false, "progress": null }
] }
]
}
}
}
Errors: 404 not found / not directly accessible · 500 unexpected failure
Opening a preview lesson
GET /api/v1/public/courses/{slug}/lessons/{lesson} ← no auth required
Serves the same shape the classroom route serves, minus progress — the key is omitted, not
null, because there is no enrollment to read one from.
404 (never 403 — a 403 would confirm that paid content exists at that address) when the lesson
is not is_preview, when it or its module is unpublished, when the course is unpublished, and
when the lesson belongs to a different course.
Answer keys are stripped here too, on this endpoint and on the curriculum outline above:
correctandexplanationnever reach a guest. Each question'stypedoes — the preview is drawn by the same component the classroom uses.
Wishlist endpoints — saved courses
Requires a Sanctum bearer token.
The wishlist is per user and global to the course. Saving a course once saves it everywhere it appears — a community only ever narrows which saved courses it lists (see Community API §17h). That is why these routes are not scoped to a community.
{course} accepts either a course UUID or the public {slug}, so the public course
page can toggle the heart with the slug it already has in the URL.
20a. List my wishlist
GET /api/v1/courses/wishlist
Every course the caller has saved, newest save first. Paginated.
Query parameters
| Param | Type | Notes |
|---|---|---|
page |
int | |
per_page |
int | 1–100 (default 15) |
Each row carries all four viewer flags — including awaits_payment, so a saved
course with an abandoned checkout renders the resume state here too. is_wishlisted is always
true on this screen by definition.
200 Response
{
"status": "success",
"message": "Wishlisted courses retrieved successfully.",
"data": {
"courses": {
"data": [
{
"id": "9b6f…", "title": "…", "slug": "from-junior-to-senior", "subtitle": "…",
"status": 2, "status_label": "Published",
"visibility": 1, "visibility_label": "Public",
"total_lessons": 12, "total_duration_minutes": 240,
"enrollment_count": 87, "average_rating": "4.60", "thumbnail_url": null,
"is_free": false, "price": "49.00", "currency": "USD",
"mentor": { "id": "8a1c…", "username": "jane", "name": "Jane Doe",
"headline": "…", "avatar_url": null },
"is_wishlisted": true,
"is_enrolled": false,
"wishlisted_at": "2026-08-12T09:14:07+00:00"
}
],
"links": { "…" },
"meta": { "current_page": 1, "last_page": 1, "total": 1, "per_page": 15 }
}
}
}
is_wishlisted is always true here — it is kept so the card shares one shape with the
community catalogue (§16) and the heart can be un-toggled in place. is_enrolled
reflects an active or completed enrollment, so the card can render Continue instead
of Enroll.
Errors: 401 unauthenticated · 422 per_page out of range
20b. Save a course
POST /api/v1/courses/{course}/wishlist
200 Response
{
"status": "success",
"message": "Course added to your wishlist.",
"data": { "is_wishlisted": true }
}
Idempotent — saving twice is not an error and never creates a second row; the response always reports the resulting state.
Errors: 401 unauthenticated · 404 no such course · 500 unexpected failure
20c. Remove a course
DELETE /api/v1/courses/{course}/wishlist
200 Response
{
"status": "success",
"message": "Course removed from your wishlist.",
"data": { "is_wishlisted": false }
}
Idempotent — removing a course that was never saved returns 200 with
"is_wishlisted": false. Only the caller's own save is removed.
Errors: 401 unauthenticated · 404 no such course · 500 unexpected failure
Mentee endpoints — enrollment
All require a Sanctum token.
21. Resolve pricing for me / quote a code
GET /api/v1/mentee/courses/{course}/pricing → { "data": { "pricing": { … } } }
POST /api/v1/mentee/courses/{course}/pricing { "coupon_code": "…" } → { "data": { "pricing": { … } } }
The GET is the POST with no code typed — same resolver, same response shape, same
data.pricing key, so one client type covers the price badge and the checkout box alike.
Layer 1 returns the minimum of the rates matching this viewer; ties break on priority, then
on the more specific audience, then on age. Layer 2 applies the running offer worth the most.
Layer 3 applies the code, if one was sent. Non-members and anonymous callers resolve to the base
price. See Community linkage.
200
{
"data": {
"pricing": {
"base_price": "99.00",
"list_price": "79.00",
"price_before_coupon": "63.20",
"price": "56.88",
"currency": "USD",
"is_free": false,
"audience_type": "specific_community",
"audience_type_label": "Specific Community",
"rate": { "rule_id": "01a0…", "label": "React Devs BD members", "community": { "id": "01a0…", "name": "React Devs BD" }, "…": "…" },
"offer": { "id": "01a0…", "label": "Launch week", "ends_at": "…", "allows_coupons": true, "status": "live", "…": "…" },
"coupon": { "id": "01a0…", "code": "EARLYBIRD", "discount_type": "percent", "discount_value": "10.00", "currency": null },
"coupon_discount_amount": "6.32",
"discount_amount": "22.12",
"savings": "22.12",
"savings_pct": 28
}
}
}
list_price is the strikethrough — the price this viewer would otherwise have paid.
base_price is context only: striking through a price someone was never eligible for advertises
a discount that was never on offer to them.
A bad code is a 422 keyed on coupon_code
Never a 200 with a null coupon. errors.coupon_code[0] is written to be rendered verbatim under
the learner's input.
| Case | Message |
|---|---|
| No such code on this course | That code isn't valid for this course. |
| Expired, not started, or paused | That code has expired. |
max_redemptions reached |
That code has been fully redeemed. |
per_user_limit reached |
You've already used that code. |
Offer has allows_coupons: false |
Codes can't be combined with the current offer. |
| Already free for this viewer | This course is already free for you. |
Errors: 404 course not found · 422 as above
22. Enroll
POST /api/v1/mentee/courses/{course}/enroll
Enrolls the user. Free (or free-for-audience) courses grant access immediately and increment
enrollment_count. Paid courses record a pending_payment enrollment that grants nothing,
snapshot the resolved price, and return a checkout_url. Access, the counter bump and any XP
all land when the payment settles.
Body (optional)
| Field | Type | Required | Rules |
|---|---|---|---|
community_id |
uuid | no | Enroll through this community — it must carry the course and have you as an active member. Omitted, the carrying community where you hold the highest level is chosen |
coupon_code |
string | no | The code, not a price. The quote (§21) is advisory: the code is re-validated and the price recomputed here, and a code that died in between fails the enrol with the same 422 shape |
A zero-price enrollment — a free course, a free rate, or a discount that took the whole price
— is granted outright: checkout_url is null and no payment provider is involved. The
enrollment echoes list_price_snapshot, offer_id, coupon_id and discount_amount alongside
price_snapshot.
The chosen community is recorded on the enrollment and fixes the price charged, the level context for module gating, and where progress XP lands. See Community linkage.
201
{
"status": "success",
"message": "Checkout started. Complete payment to unlock the course.",
"data": {
"enrollment": { "…enrollment object, status pending_payment, awaits_payment true…" },
"checkout_url": "https://…"
}
}
checkout_url is null for a free enrollment. Redirect whenever it is present, and never read
is_free to decide — the response is the authority, so a course whose price changed
mid-session still routes to payment. This mirrors the community join flow.
Resuming an abandoned checkout.
course_enrollmentsis unique on(user_id, course_id), so re-posting to/enrollreuses the outstandingpending_paymentrow with a refreshed price snapshot rather than colliding with it. Noenrolled_at, no counter bump and no XP are awarded until payment settles — otherwise a learner could farm XP by starting checkouts they never pay for. Render the resume-payment state offawaits_payment.
Errors
| Code | When |
|---|---|
| 422 | Already enrolled · course not published · community_only course and you're not an active member of any carrying community · supplied community_id does not carry the course or you're not an active member of it |
| 502 | The gateway refused to open a checkout. The pending_payment enrollment survives, so retrying picks it up |
| 404 | Course not found |
| 401 | Unauthenticated |
Active members of any community carrying a
community_onlycourse can enroll (that community's member price applies); only outsiders are blocked.
23. List / show my enrollments
GET /api/v1/mentee/courses/enrollments → paginated { "data": { "enrollments": { data, links, meta } } }
GET /api/v1/mentee/courses/enrollments/{id} → { "data": { "enrollment": { …incl. course… } } }
Query (list): search (matched against the course — title / subtitle / summary / description /
mentor name), status (enrollment status), page, per_page (default 15).
Errors (show): 404 not the caller's enrollment.
The nested course on each row carries the four viewer flags, so a card can
un-toggle the heart — or render the resume-payment state — without a second call.
Self-only, and enrollment-shaped. These return enrollment records (progress, price snapshot, status) for the caller. To list the courses another user is enrolled in, use
GET /api/v1/user/{username}/courses?type=enrolled(Authentication API, endpoint 42) — it returns course objects, not enrollments, and carries no progress or pricing detail.
Mentee endpoints — learning & progress
Access requires an active/completed enrollment for the course; otherwise 403 (content)
or 404 (progress actions resolve the enrollment first).
24. Get course content
GET /api/v1/mentee/courses/{course}/content
Returns the full unlocked curriculum (published modules → lessons) with the learner's
per-lesson progress, plus the enrollment. Touches last_accessed_at.
Quiz safety: the learner content strips each quiz question's
correctandexplanationkeys — answer keys are never sent to the client. Assignment lessons expose their brief (total_marks,passing_marks,instructions,attachments) but no grading internals.
typesurvives the strip. It is not part of the answer key — it says whether the question takes one option or several, which is what the client draws radios or checkboxes from. A quiz written before the field existed has itstypederived from the answer key on the way out, so old courses render correctly too and nothing needs backfilling.
200 (excerpt)
{
"data": {
"course": {
"id": "9b6f…", "title": "…",
"modules": [
{ "id": "1a2b…", "title": "Foundations",
"lessons": [
{ "id": "3c4d…", "title": "Intro", "lesson_type": "video", "video_url": "https://…",
"time_limit_minutes": null, "quiz": null, "assignment": null,
"progress": { "status": "completed", "completed_at": "…", "last_position_seconds": 620, "quiz_score_pct": null } },
{ "id": "4d5e…", "title": "Quiz", "lesson_type": "quiz", "time_limit_minutes": 30,
"quiz": { "pass_pct": 70, "questions": [ { "prompt": "…", "type": "single", "options": ["…"] } ] },
"assignment": null, "progress": null },
{ "id": "5e6f…", "title": "Assignment", "lesson_type": "assignment", "time_limit_minutes": 60,
"quiz": null,
"assignment": { "total_marks": 100, "passing_marks": 50, "instructions": "…",
"attachments": [ { "id": "…", "name": "brief.pdf", "url": "https://…" } ] },
"progress": null }
] }
]
},
"enrollment": { "…enrollment object…" }
}
}
The progress object also carries assignment/timer fields when present: started_at,
submitted_at, submission_note, submission_file_url, is_late, awarded_marks,
graded_at, grade_feedback.
Errors: 403 not enrolled
25. Get a single lesson
GET /api/v1/mentee/courses/{course}/lessons/{lesson}
Returns one lesson's full content with this learner's progress attached. The same quiz-answer
stripping as endpoint 24 applies — correct and explanation go, each question's type stays.
Only published lessons of published modules are reachable; a lesson inside a
level-locked module is withheld. Touches last_accessed_at.
200
{
"data": {
"lesson": {
"id": "3c4d…", "module_id": "1a2b…", "title": "Intro",
"lesson_type": "video", "lesson_type_label": "Video",
"position": 1, "duration_minutes": 12, "time_limit_minutes": null, "is_preview": false,
"text_content": null, "video_provider": "youtube", "video_url": "https://…",
"quiz": null,
"assignment": null,
"attachment_url": null,
"progress": { "status": "completed", "completed_at": "…", "last_position_seconds": 620, "quiz_score_pct": null }
}
}
}
Errors: 404 not enrolled / lesson not found (or unpublished) · 403 lesson's module is locked
26. Mark a lesson complete
POST /api/v1/mentee/courses/{course}/lessons/{lesson}/complete
Marks the lesson completed (idempotent) and rolls up the enrollment: completed_lessons_count,
progress_pct (floor(completed / published_total × 100)), and — at 100% — flips the
enrollment to completed and sets completed_at.
200 → { "data": { "enrollment": { … updated … } } }
Errors: 404 not enrolled / lesson not in course · 422 lesson not part of the course
27. Start a lesson (timer)
POST /api/v1/mentee/courses/{course}/lessons/{lesson}/start
Stamps started_at on the learner's progress so a timed lesson's window can be enforced.
Required before submitting a quiz/assignment that has a time_limit_minutes. Calling it
again does not reset the clock. Untimed lessons don't need it.
200 → { "data": { "started_at": "2026-07-03T09:00:00+00:00", "time_limit_minutes": 30 } }
Errors: 404 not enrolled / lesson not found
28. Submit a quiz
POST /api/v1/mentee/courses/{course}/lessons/{lesson}/quiz
Scores the submission and, on a pass (score ≥ pass_pct), marks the lesson complete.
A question is correct when the selected indices exactly match the expected set. If the
lesson has a time_limit_minutes, it must have been started and
the window not exceeded — otherwise 422 (a quiz is a hard cutoff; see Timer).
Payload
{ "answers": [ [1], [0, 2] ] }
answers[i] is the array of selected option indices for question i.
200
{
"data": {
"score_pct": 100,
"passed": true,
"enrollment": { "…enrollment object…" }
}
}
Errors: 422 lesson is not a quiz · not started / time limit exceeded · 404 not enrolled / lesson not found
29. Submit an assignment
POST /api/v1/mentee/courses/{course}/lessons/{lesson}/assignment (multipart/form-data)
Uploads the learner's solution file and sets the lesson submitted, awaiting the mentor's
grade. Resubmitting replaces the file and clears any prior grade.
A submission past the lesson's time_limit_minutes is still accepted but flagged
is_late: true, and the note becomes required as the reason (see Timer).
Payload
| Field | Type | Required | Notes |
|---|---|---|---|
file |
file | yes | pdf,doc,docx,ppt,pptx,xls,xlsx,zip,png,jpg,jpeg, ≤ 10 MB |
note |
string | conditionally | Optional normally; required when the submission is late |
201
{
"data": { "submission": { "…submission object (status: submitted)…" } }
}
Errors: 422 lesson is not an assignment · late submission without a note · missing/invalid
file · 404 not enrolled / lesson not found
30. Save video position
PATCH /api/v1/mentee/courses/{course}/lessons/{lesson}/position
Persists a resume position without completing the lesson (won't demote an already-completed lesson).
Payload
{ "position_seconds": 125 }
200 → { "message": "Position saved." }
Errors: 422 missing/negative position_seconds · 404 not enrolled
Progress model reference
enroll ─► ACTIVE ──(mark complete / pass quiz / assignment graded pass)──► progress_pct rises
│
└─(all published lessons complete)─► COMPLETED (+ completed_at)
- progress_pct =
floor(completed_lessons / published_lesson_count × 100). - A lesson counts as completed via mark-complete, a passing quiz, or an assignment graded as a pass.
video/text/pdflessons complete via endpoint 26;quizlessons complete by passing endpoint 28 (or can be force-completed via 26).assignmentlessons goin_progress→submitted(learner uploads via endpoint 29) →completedwhen the mentor grades a pass (endpoint 16). A graded fail drops back toin_progressso the learner can resubmit.
Timer / time limits
A quiz or assignment lesson may carry a time_limit_minutes. Enforcement is server-side,
keyed off started_at (set by endpoint 27):
- No
time_limit_minutes→ no timer;startisn't needed. - Quiz (hard cutoff) — the lesson must be started, and a submission after
started_at + time_limit_minutesis rejected with422. - Assignment (late allowed) — a late submission is accepted but flagged
is_late: trueand itsnotebecomes required (the reason for lateness). The mentor seesis_latewhen grading.
Community linkage
A course can be carried by several communities, priced independently in each. Three pieces combine; all are live.
1. Carrying communities — community_course pivot. The mentor sets the set via
community_ids on create/update (§2, §4); every entry must be a community they own, and at
least one is required for community_only visibility. The set drives:
- Visibility —
community_onlycourses stay hidden from public browse/slug lookup and are surfaced only through a carrying community's gated catalog (GET /{mentee|mentor}/communities/{community}/courses— same endpoint on both surfaces). Enrolling requires active membership of any carrying community. - Level gating —
unlock_at_levelis only accepted on a course some community carries; levels are per-community, so an unlinked course has no level to check against.
2. Enrolling community — course_enrollments.community_id. Because levels, XP and price are
all per-community, an enrollment records which community the access came through. It is fixed
at enrollment and drives:
⚠️ The community level ladder was renumbered — the floor is now level 0 and tiers start at 1. Stored
unlock_at_levelvalues were shifted down by one alongside the thresholds they point at, so existing gates still admit exactly the same learners. See the breaking-change note incommunity-api.md.
- Module locking — a module's
unlock_at_levellocks it until the learner reaches that level in the enrolling community. In learner content, locked modules are withheld. - XP —
lesson_completed(+20) /course_completed(+100) are awarded in the enrolling community only. Awarding across every carrying community would multiply one lesson's XP. - Price — see below.
Resolution order when enrolling: an explicit community_id in the request body (which must both
carry the course and have you as an active member), otherwise the carrying community where you
hold the highest level, otherwise null for a public/standalone enrollment. A null
enrolling community means no level context (gated modules stay locked) and no XP.
3. Per-community pricing — course_pricing.community_id. A mentor adds pricing rows (§17)
scoped to any community they own. Pricing resolves in the community's context: browsing or
enrolling through a community quotes that community's price, not the best price the member
holds elsewhere. A course free in community A and $9 in community B costs a member of both
nothing in A and $9 in B.
Within a single community, precedence is specific_community → community_member → public
(ties broken by the cheapest option). Non-members, anonymous callers, and contexts with no
community always resolve to the public price.
Community deletion. Deleting a community unlinks its courses. A community_only course left
carried by nothing would be visible to nobody, so it is automatically downgraded to unlisted —
still hidden from search, still reachable by direct link. Courses carried by another community
are unaffected.
See the Community API doc's "Course gating" section for the membership/level/XP mechanics from the community side.
Reviews
Requires a Sanctum bearer token. {course} takes a course UUID or the public slug.
31. Course reviews
GET /api/v1/courses/{course}/reviews → paginated list + my_review + summary
POST /api/v1/courses/{course}/reviews → write or revise the caller's review
DELETE /api/v1/courses/{course}/reviews/me → remove the caller's review
Review object
{
"id": "…",
"course_id": "9b6f…",
"rating": 5,
"body": "Excellent.",
"is_mine": false,
"author": { "id": "…", "username": "…", "name": "…", "avatar_url": "…" },
"created_at": "2026-07-28T10:12:00+00:00",
"updated_at": "2026-07-28T10:12:00+00:00"
}
GET 200
{
"data": {
"reviews": { "data": [ { "…review…" } ], "links": { … }, "meta": { … } },
"my_review": { "…review…" },
"summary": {
"average_rating": "4.60",
"rating_count": 5,
"rating_breakdown": { "5": 3, "4": 2, "3": 0, "2": 0, "1": 0 }
}
}
}
my_reviewis the caller's own review hoisted out of the list so the form can prefill for editing.nullwhen they have not written one — not an error.is_mineis on every row; the client hides the edit affordance by it.summaryrides along so the reviews tab needs one call rather than two.
POST body: { "rating": 1–5, "body"?: string }
Only an enrolled learner may post (403 otherwise), and posting twice updates the existing review rather than creating a second one — one review per user per course, enforced by a unique key. Returns 201 on create and 200 on update.
Every write recomputes average_rating, rating_count and rating_breakdown on the course
inside the same transaction, so the summary can never disagree with the reviews below it.
The course author is notified (course.reviewed) on the first review only — an edit is the
same learner revising the same opinion.
Errors: 403 not enrolled · 404 course not found / no review to delete · 422 rating
outside 1–5
Authors
32. Authors
GET /api/v1/courses/{course}/authors ← anyone reading the course page
GET /api/v1/mentor/courses/{course}/authors ← owner
POST /api/v1/mentor/courses/{course}/authors ← owner
DELETE /api/v1/mentor/courses/{course}/authors/{author} ← owner
200
{
"data": {
"authors": [
{
"id": null,
"role": "owner",
"role_label": "Course author",
"mentor_id": "8a1c…",
"user_id": "4f2b…",
"username": "janedoe",
"name": "Jane Doe",
"headline": "Product leader",
"bio": "…",
"avatar_url": null,
"average_rating": "4.72",
"reviews_count": 41,
"students_count": 380,
"courses_count": 6
},
{ "id": "…", "role": "co_author", "role_label": "Co-author", "mentor_id": null, "…": "…" }
]
}
}
The owner is synthesised, never stored. It is courses.mentor_id, placed at the head of the
listing, which is what guarantees exactly one owner and stops the credit list from drifting out of
agreement with the course. Its id is null — that credit is not a row and cannot be revoked.
Credited rows carry mentor_id: null, since a co-author need not be a mentor at all.
POST body: { user_id | username, role } where role is co_author or assistant.
owner is rejected with a 422 — the owner is derived and cannot be assigned.
⚠️ Credit only. A credited author appears on the Authors tab and gains no authoring rights: they cannot edit the course, its modules, its lessons or its pricing. Those stay with
courses.mentor_idthroughout the mentor API. Widening that means rewriting the ownership check on every mentor course route, which is a separate change.
Errors: 404 course / author not found · 422 already credited, already the owner, or
role: owner
The management rail
Nine sidebar screens on the course page, addressed as ?tab=settings§ion=…. Two conventions
apply to all of them.
Owner routes and learner routes are separate. Where both sides need the same data:
/mentor/courses/{course}/…— the author's view, with drafts and everyone's rows/courses/{course}/…— the enrolled learner's view, scoped to what they may see
Everything is paginated. Every list returns the Laravel envelope with meta.current_page /
meta.last_page, even where today's screen renders one page.
Learner routes resolve through a participation check — owner or enrolled with access. A guest reading the sales page reaches none of it, and gets 403.
33. Students
GET /api/v1/mentor/courses/{course}/students
PATCH /api/v1/mentor/courses/{course}/students/{enrollment} → { "status": "cancelled" }
DELETE /api/v1/mentor/courses/{course}/students/{enrollment}
Query: search (name / username), status, page, per_page.
{
"id": "…",
"course_id": "9b6f…",
"user_id": "…",
"status": "active",
"status_label": "Active",
"progress_pct": 68,
"completed_lessons_count": 17,
"enrolled_at": "2026-06-14T09:20:00+00:00",
"last_accessed_at": "2026-08-17T18:05:00+00:00",
"is_online": true,
"can_chat": true,
"can_view_profile": true,
"user": { "id": "…", "username": "…", "name": "…", "avatar_url": "…" }
}
id is the enrolment id, not a user id — it is what the two moderation routes address.
- The roster never shows
pending_payment: they still owe money and are not a student yet. statusfilters onactive·completed·cancelled·expired.PATCHaccepts those same four.pending_paymentandrefundedare not assignable — the payment flow owns them, and hand-writing one would desynchronise the enrolment from the money.- Revoking access decrements
enrollment_count, floored at zero.
⚠️
can_chat/can_view_profileare notCommunityMember's logic, though the contract is identical. Those readchat_access/profile_visibility, per-membership columns a course enrolment has no equivalent of. Here they resolve the global block relationship, one query for the page. Absent means allowed, same as the members list.is_onlinereadslast_accessed_atagainst the same window, which for a course means "active in this course recently" rather than "signed in somewhere".
34. Coursework — submissions
GET /api/v1/mentor/courses/{course}/submissions ← the grading queue
GET /api/v1/courses/{course}/submissions/me ← the learner's own work
One endpoint feeds both rail screens: "Assignments" is type=assignment and "Quizzes" is
type=quiz. Both render submissions, not definitions.
Query: type (assignment | quiz), status, search (learner name), page, per_page.
{
"id": "…",
"type": "quiz",
"type_label": "Quiz",
"enrollment_id": "…",
"lesson_id": "…",
"lesson_title": "Array methods quiz",
"module_id": "…",
"module_title": "Arrays",
"status": "submitted",
"status_label": "Submitted",
"learner": { "id": "…", "username": "…", "name": "…", "avatar_url": "…" },
"submitted_at": "2026-04-17T13:05:00+00:00",
"is_late": false,
"awarded_marks": null,
"total_marks": 10,
"quiz_score_pct": null,
"graded_at": null,
"feedback": null,
"note": null,
"file_url": null
}
This is the submission object widened with type, type_label,
lesson_title, module_id, module_title and total_marks. learner.email is gone,
replaced by avatar_url — the table renders an avatar and never rendered the address.
⚠️
statushere is a presented value. The storedLessonProgressStatusisin_progress·submitted·completed;gradedandlateare derived fromgraded_atandis_late. The four-value vocabulary —pending·submitted·graded·late— works on both read and filter without widening the stored enum.
An untouched in_progress row is not a submission and never joins the queue.
total_marks comes from assignment_payload.total_marks, quiz_payload.total_marks, or the
question count.
Grading stays where it was — §16, nested under module and lesson.
35. Notices
Short announcements pinned above the course. Plain text, authored by the owner, read by everyone enrolled.
GET /api/v1/mentor/courses/{course}/notices
POST /api/v1/mentor/courses/{course}/notices → { body, is_pinned? }
PUT /api/v1/mentor/courses/{course}/notices/{notice}
DELETE /api/v1/mentor/courses/{course}/notices/{notice}
GET /api/v1/courses/{course}/notices ← enrolled learner, read-only
{
"id": "…",
"course_id": "9b6f…",
"body": "Week 3 live session moves to Thursday 18:00 UTC.",
"is_pinned": true,
"comments_count": 4,
"author": { "id": "…", "username": "…", "name": "…", "avatar_url": "…" },
"created_at": "2026-08-12T09:00:00+00:00",
"updated_at": "2026-08-12T09:00:00+00:00"
}
Ordered pinned first, then created_at descending.
Every announcement is a thread
An announcement saying "Thursday's session moves to 18:00" invites questions, so each one owns a
thread in the course discussion: a comment carries the
notice_id of the announcement it answers, and GET /courses/{course}/comments?notice_id={id}
reads just that thread. No second comment system — the same rows, one more column.
comments_count is the size of that thread, so a board of twenty announcements renders "4 replies"
on each without twenty requests.
Deleting an announcement deletes its thread. A queue of questions about an announcement has nothing left to be about once it is gone. Reactions and mention rows on those comments go with them.
bodyis plain text, not Lexical. The editor is a bare textarea and the list renders withwhitespace-pre-wrap, so Lexical JSON written here would display as literal JSON.
Publishing a notice fans out course.notice_published to everyone enrolled — see the
Notification System. The fan-out is chunked, because a popular course's
roster is not a page.
36. Materials
Files, links and recordings attached to the course rather than to one lesson — the reference shelf a learner keeps coming back to.
GET /api/v1/mentor/courses/{course}/materials
POST /api/v1/mentor/courses/{course}/materials
PATCH /api/v1/mentor/courses/{course}/materials/{material}
DELETE /api/v1/mentor/courses/{course}/materials/{material}
GET /api/v1/courses/{course}/materials ← enrolled learner, same resource
{
"id": "…",
"course_id": "9b6f…",
"type": "video",
"type_label": "Video",
"title": "Introduction to React",
"description": null,
"url": "https://…",
"thumbnail_url": "https://…",
"has_poster": true,
"duration_seconds": 754,
"size_bytes": 48210432,
"mime_type": "video/mp4",
"position": 1,
"created_at": "2026-07-02T10:00:00+00:00",
"updated_at": "2026-07-02T10:00:00+00:00"
}
| Field | Notes |
|---|---|
type |
video · image · file · link. The row's icon and layout switch on it |
url |
The external URL for a link; the stored file's URL for everything else. The split is hidden here so the client renders one row shape |
thumbnail_url |
The author's poster if they uploaded one, otherwise an image material's own file. null elsewhere, and the row falls back to a type icon |
has_poster |
Whether a poster was uploaded. Lets Settings say "Replace poster" rather than "Add poster" without inferring it from thumbnail_url, which an image material populates from its own file |
duration_seconds |
video only |
size_bytes / mime_type |
Stored files only; null for links |
position |
Author-defined order. Sorted by it, created_at descending as the tiebreak |
POST — two body shapes, one per source:
{ "type": "link", "title": "Official documentation", "url": "https://react.dev" }
{ "type": "video", "title": "Lesson recording", "upload_id": "…" }
Poster frames
A materials list of recordings is a column of identical play icons without one. Posters are author-supplied, not extracted — this application has no ffmpeg, and adding one to draw a single still would be a large dependency for a small picture.
Send an optional poster_upload_id on create or update, from the same chunked flow using the
course_material_image purpose — whatever the material's own type is, since a poster is an
image even when the thing it previews is a 2 GB recording.
{ "type": "video", "title": "Lesson recording", "upload_id": "…", "poster_upload_id": "…" }
Accepted for every type, links included: a link to a recorded talk is helped by a still exactly as much as an uploaded one.
On PATCH you send |
What happens |
|---|---|
| key absent | the poster is left alone |
poster_upload_id: "…" |
the poster is replaced |
poster_upload_id: null |
the poster is removed; the row falls back to a type icon |
Unlike the material's file, the poster is mutable — it is presentation, not content, so an author can add or change one without re-uploading a 2 GB recording.
A thumb conversion (480×270, cropped) is generated on upload. Conversions are queued, so
thumbnail_url serves the original in the short window before it lands rather than null —
the row is never left without a picture it could have shown.
If frame extraction ever arrives for another reason, it populates this same collection and nothing on the wire changes.
There is no multipart path, on purpose.
upload_idcomes from the existing chunked upload flow, using thecourse_material_video/course_material_image/course_material_filepurpose that matchestype— so a session opened for a PDF can never be spent as a lecture recording. A 2 GB recording cannot go through a plain form post, and a second upload path would have to be maintained forever.
PATCH takes title, description and position only. A material's file is immutable —
replacing bytes means delete and re-add, so a link someone saved never silently becomes a
different file.
37. Notes
Private study notes — many per learner per course, each optionally anchored to a chapter, a lesson, or a moment in a lesson's video. A note with no anchors is a general course note. Nobody else, the course author included, can read them.
GET /api/v1/courses/{course}/notes → { "notes": [ … ] }
POST /api/v1/courses/{course}/notes → 201 { "note": {…} }
PATCH /api/v1/courses/{course}/notes/{note} → 200 { "note": {…} }
DELETE /api/v1/courses/{course}/notes/{note} → 204
{ "data": { "note": {
"id": "…",
"course_id": "9b6f…",
"body": "{\"root\":{\"children\":[…]}}",
"module_id": "…", "module_title": "Reliability",
"lesson_id": "…", "lesson_title": "Retries and backoff",
"lesson_type": "video", "lesson_type_label": "Video",
"timestamp_seconds": 214,
"created_at": "2026-08-15T14:22:00+00:00",
"updated_at": "2026-08-15T14:22:00+00:00"
} } }
bodyis Lexical JSON serialized as a string, stored aslongText— never a length-capped varchar. There is no title — the list heads each note with its own first line, so there is nothing to keep in sync.- All five anchor fields are always present,
nullwhen unset. A client should not have to tell an absent key from an explicit null to know a note is unanchored. - The chapter is derived from the lesson. Send only
lesson_idand the note comes back carryingmodule_idandmodule_titletoo — which is what makes?module_id=pick up the lesson notes underneath it without the client sending both. - The titles outlive the ids. Deleting a lesson does not delete the notes on it: the foreign
keys null out and the denormalised
lesson_title/module_title/lesson_typesurvive, so the learner keeps what they wrote and it still routes to the right player.
Filters on the list: search, module_id, lesson_id, sort.
searchreads a plain-text shadow column written on save, not the Lexical JSON — so searchingparagraph, a word in every Lexical document ever written, matches nothing.sort=lessonorders by module position → lesson position → timestamp. Unanchored notes sort last — they belong to the course rather than to any point in it.- The list is not paginated. This is one learner's notes on one course, which the filters narrow better than pages would.
Errors: 422 an anchor pointing at a lesson or chapter in a different course, or a
timestamp_seconds with no lesson_id.
Deprecated — the singular routes.
GET/PUT /courses/{course}/notes/mestill answer. They read and upsert the caller's most recent note rather than 404ing, so a client that has not moved to the collection keeps working. Rows written under the old one-per-course unique index are now first-class members of the collection, as general course notes with no anchors, and their search shadow was backfilled — they are findable without needing a resave.
Privacy is the whole feature. The user is taken from the token and never from the route, so there is no address at which someone else's note exists — the course owner hitting this route gets their own note. These appear in no owner-facing export, no students endpoint and no admin listing, and
CourseNoteServicedeliberately exposes no method that would enable one.
38. Comments — the course discussion
This is the third instance of one contract, not a third contract. The
feed's comment object and
Threading is unlimited; display is not
specify the model in full: unlimited stored depth, depth / root_id / parent_author /
mentions on every comment, replies as a bounded flat preview, replies_count as the total
descendant count, author.is_me at every depth. The resource here is field-for-field identical
apart from course_id replacing post_id, plus one course-only field — notice_id, which
scopes a comment to an announcement's thread.
GET /api/v1/courses/{course}/comments?notice_id={notice}
POST /api/v1/courses/{course}/comments → { body, notice_id?, parent_id?, mentioned_user_ids? }
GET /api/v1/courses/{course}/comments/{comment}/replies
POST /api/v1/courses/{course}/comments/{comment}/reactions → { type? } (default like)
DELETE /api/v1/courses/{course}/comments/{comment}
POST /api/v1/courses/{course}/comments/{comment}/report → { reason, details? }
Scoped by announcement
Every announcement owns its own thread, and the flat course-wide discussion still exists underneath it.
notice_id on the comment |
The announcement this comment answers. null is the course-wide discussion — still served, still postable |
?notice_id={id} on the read |
Just that announcement's thread. Absent, behaviour is unchanged: the whole discussion, every thread included |
notice_id on the write |
Files the comment under that announcement |
A
notice_idbelonging to another course is a 422, on both the read and the write — never a silent drop, which would file the comment on the course-wide discussion where the learner who asked will never see it again.
A reply inherits its parent's thread. Send parent_id alone and the reply lands in the same
announcement's thread; a notice_id that disagrees with the parent's is a 422 rather than a
comment filed under one announcement while answering another.
Who may post: enrolled learners and the author. A guest sees nothing — the discussion is not part of the public course payload, and every route resolves through the participation check.
Deletion: the comment's author, or the course owner, the same way a community owner can
moderate their own space. Deleting takes the whole subtree with it — a reply has no meaning
without the comment it answers. author.is_me decides the author's own delete affordance; the
owner's comes from viewerRole === "owner".
Deleting an announcement deletes its whole thread the same way, reactions and mention rows included — see §35.
Reactions ride on the same polymorphic table the feed and community comments use, so the like button behaves identically on all three. Posting the type you already hold removes it, which is what makes it a toggle.
Mentions resolve against enrolled learners and the author only — mentioning someone who
cannot read the thread would notify them into a room they cannot enter. mentioned_user_ids is a
hint from the picker; the body is the source of truth and can neither add nor remove a mention.
Notifications: course.comment_mentioned takes precedence over course.commented, so one
recipient gets one notification.
A comment is always resolved scoped to its course. Addressing a comment id from one course through another course's route is a 404 — that is what stops one course's access check being bypassed through another's.
Deferred / cross-phase (not yet enforced)
| Item | Column / enum present | Unblocks with |
|---|---|---|
| Premium-exclusive courses | courses.is_exclusive_to_premium |
Phase 7 — Platform Subscriptions |
| Enrollment expiry | EnrollmentStatus::Expired |
Time-limited access — the status exists so clients can filter on it, but nothing sets it |
| Co-authors with authoring rights | course_authors.role |
Widening the ownership check across every mentor course route (§32) |
| Automatic video poster extraction | author-supplied posters ship (§36) | An ffmpeg dependency; the author-supplied poster covers the case meanwhile and the wire shape would not change |
cta_url on the storefront |
— | Deliberately dropped. A buy button pointing off-platform takes payment outside the platform's settlement path. cta_label ships and renders over the normal checkout |
| Feed / community comments on the shared threading core | AbstractThreadedCommentService |
Courses is built on it; the two shipped surfaces still carry their own copies and are migrated separately |
@ mention autocomplete on course comments |
mentions themselves ship (§38) | Never built and not asked for. The body is the source of truth, so a picker is a convenience: the feed and community have one, courses do not |
Decisions taken rather than asked about, recorded so they are easy to overrule:
per_user_limiton a coupon defaults to unlimited, not 1 (§18b).- Community coupons are not redeemable at join time. The validate-and-redeem mechanism is
polymorphic and shared; wiring it is a
coupon_codeon the community join call — see the Community API deferred list.