# Courses API — Admin Dashboard

Admin management of recorded & live courses: create/update (split by type), the content-review workflow, live-session scheduling, read-only curriculum review, completion reporting, and enrollment visibility.

- **Base path:** `{{base_url}}/admin` (e.g. `http://localhost:8000/api/v1/admin`)
- **Auth:** `Authorization: Bearer {{admin_token}}` on every endpoint
- **Middleware stack:** `api, set_locale, cors, auth:api, active, rbac.auto`
- **RBAC:** route name minus its `admin.` prefix is the permission checked (e.g. `admin.courses.recorded.store` → `courses.recorded.store`)
- **Source:** `app/Http/Controllers/Api/V1/Admin/{Course,LiveCourse,RecordedCourse,CourseWorkflow,CourseLesson,CourseSession}Controller.php`, `app/Services/Admin/CourseService.php`, `app/Services/Education/{CourseWorkflowService,LiveScheduleService,CurriculumService,EnrollmentService}.php`, `app/Models/Course.php`

## Conventions

Same envelope, headers, and media-hash conventions as [books-dashboard.md](./books-dashboard.md#conventions) — `{status, message, data}`, upload media via `POST {{media_base_url}}/media` first and pass the returned `hash`.

**Two course types, two create/update endpoints.** `course_type` is **never** a client field — it is fixed by which endpoint you call (`courses/recorded` vs `courses/live`) and is `prohibited`/immutable afterwards. Index/show/destroy stay type-agnostic under `/admin/courses`.

**`start_date` is read-only** — always derived as `MIN(session_date)` over a live course's scheduled sessions; it is never accepted in a request body.

**Admin only reads curriculum** (lessons/quizzes) — trainers author it. Use `admin.lessons.show` / `admin.courses.lessons.index` to review before approving.

### How the courses list works (index pipeline) — `GET /admin/courses` only

Goes through the same shared pipeline documented in [books-dashboard.md § How list endpoints work](./books-dashboard.md#how-list-endpoints-work-index-pipeline). `Course::$searchableFields = ['slug', 'category_id', 'trainer_id', 'course_type', 'content_status', 'is_visible', 'name', 'description', 'created_at']`; translated: `name`, `description` (of these, only `name`/`description` are searchable/filterable — the course model's other translated blocks like `demo_text`, `goals`, etc. aren't in `$searchableFields` at all, so they can't be searched, filtered, or sorted on).

⚠️ **Course-specific filtering pitfalls (in addition to the general ones in the books doc):**
- **`filters[category_id]` and `filters[trainer_id]` do *not* accept the uuid the API gives you elsewhere.** Unlike `Book`, `Course` has no `scopeCategory_id()`/`scopeTrainer_id()` — `ApplyFilters` falls back to a plain `WHERE category_id = '<uuid>'` against an **integer** column, which simply matches nothing. There is currently no working way to filter the admin course list by category/trainer uuid through this endpoint.
- `filters[name]` / `filters[description]` are translated attributes with no real column on `courses` → **500 "Database error"**, exactly like Book's `title`/`author`/`description`. Use `search=` instead.
- **The `/{course}/lessons` and `/{course}/sessions` sub-lists below do *not* go through this pipeline at all** — they're plain unpaginated queries. `page`/`per_page`/`search`/`sort`/`filters` have **no effect** on them; sending any is silently ignored.

## Error codes used in this domain

| HTTP | error_code | Thrown when |
|---|---|---|
| 404 | *(fail, `Data not found`)* | Course/lesson/session uuid doesn't resolve |
| 422 | `BUSINESS_RULE_VIOLATED` | Wrong-type endpoint used (`courses/live/update` on a recorded course, and vice versa); this course's content isn't ready to publish (`errors` in `context`, debug-mode only — use `publish-check` to get the list unconditionally) |
| 422 | `INVALID_STATE_TRANSITION` | `approve` / `request-revision` called from a `content_status` other than `pending_review` |
| 422 | *(fail, standard validation)* | FormRequest rule failures (`LiveCourseRequest`, `RecordedCourseRequest`, `SessionPlaceholderRequest`, `ScheduleSessionRequest`) |
| 500 | *(error, "Database error")* | `filters[name]` / `filters[description]` on `GET /admin/courses` — see pitfalls above |

---

## Cycle

### A. Recorded course: author, review, publish

Curriculum authoring itself (lessons, quizzes, materials) happens on the **trainer** side — see [courses-trainer.md](./courses-trainer.md). This is the admin half of the loop.

| # | When | Method & Endpoint | Notes |
|---|---|---|---|
| 1 | Admin creates the course shell | `POST /admin/courses/recorded` | `content_status` starts `draft`; assign `trainer_id` so the trainer can see it |
| 2 | *(trainer authors lessons/quizzes, then submits)* | `POST /trainer/courses/{course}/submit-for-review` | `content_status: draft/needs_revision → pending_review` |
| 3 | Admin reviews curriculum | `GET /admin/courses/{course}/lessons` → `GET /admin/lessons/{lesson}` | Read-only, unpaginated — includes quiz questions/options |
| 4a | Admin approves | `POST /admin/courses/{course}/approve` | `pending_review → approved`; only from `pending_review` (else `INVALID_STATE_TRANSITION`) |
| 4b | Admin sends back | `POST /admin/courses/{course}/request-revision` | `pending_review → needs_revision`; trainer's edit window (`draft`/`needs_revision`) reopens |
| 5 | Sanity check before going live | `GET /admin/courses/{course}/publish-check` | Returns every blocker at once, e.g. `trainer_missing`, `curriculum_empty`, `quiz_without_questions`, `invalid_threshold` — never changes state |
| 6 | Publish | `POST /admin/courses/{course}/publish` | Re-validates the same rules; sets `content_status: approved, is_visible: true`. 422 if any blocker remains |
| 7 | Hide again if needed | `POST /admin/courses/{course}/unpublish` | Only flips `is_visible: false` — content status untouched |
| 8 | *(users enroll/learn)* | — | See [courses-user.md](./courses-user.md#cycle) |
| 9 | Track outcomes | `GET /admin/courses/{course}/completion-summary` | Enrollment count, average progress, completion rate |
| 10 | Who's enrolled | `GET /admin/enrolled-users?course_id=...` | Shared with training-program enrollments |

### B. Live course: schedule, publish, run

Live courses skip the recorded-course review loop entirely — the admin owns structure and publishes directly.

| # | When | Method & Endpoint | Notes |
|---|---|---|---|
| 1 | Admin creates the course | `POST /admin/courses/live` | `max_seats` required; `has_quiz` / `video_completion_threshold_default` / `cert_required_quiz_pass_percent` are `prohibited` (live has no quizzes) |
| 2 | Phase A — placeholder sessions (so the course can publish and take enrollments before any calendar exists) | `POST /admin/courses/{course}/sessions` | `{count, duration_minutes, mode}` — creates N undated sessions |
| 3 | Publish (a live course may go live with an undated Phase‑A calendar) | `POST /admin/courses/{course}/publish` | Blockers: `trainer_missing`, `max_seats_required`, `no_sessions`, `live_quizzes_forbidden`, … |
| 4 | *(users enroll — capped by `max_seats`, race-safe under a row lock)* | — | See [courses-user.md](./courses-user.md#cycle) |
| 5 | Phase B — attach real date/time as it firms up | `PUT /admin/sessions/{session}` | `{session_date, start_time, meeting_link?}` — recomputes `course.start_date` in the same transaction |
| 6 | Change of plan | `POST /admin/sessions/{session}/unschedule` | Returns the session to Phase A (clears date/time) |
| 7 | Session cancelled entirely | `DELETE /admin/sessions/{session}` | Recomputes `start_date` afterwards |
| 8 | *(learners join; attendance recorded)* | — | `POST /user/sessions/{session}/join` — see courses-user.md |
| 9 | Track outcomes | `GET /admin/courses/{course}/completion-summary` | Adds `sessions_total`, `average_attendance_rate` for live courses |

---

## Courses

### `GET /admin/courses` — List

**Query parameters**

| Key | Type | Example | Notes |
|---|---|---|---|
| `page` | integer | `1` | — |
| `per_page` | integer | `15` | max 100 |
| `paginate` | boolean | `false` | disables pagination, caps at 500 rows |
| `search` | string | `financial modelling` | matches `name`/`description` (translated, current locale) plus `slug`/`category_id`/`trainer_id`/`course_type`/`content_status`/`is_visible`/`created_at` via LIKE (the non-text ones rarely match anything meaningful) |
| `sort[created_at]` | `asc`\|`desc` | `desc` | or `sort[name]`/`sort[description]` (translation-aware), `sort[slug]`, `sort[course_type]`, `sort[content_status]`, `sort[is_visible]` |
| `filters[course_type]` | enum | `recorded` | `recorded`\|`live`, plain equality |
| `filters[content_status]` | enum | `draft` | `draft`\|`pending_review`\|`needs_revision`\|`approved` |
| `filters[is_visible]` | `1`\|`0` | `1` | plain equality |
| `filters[slug]` | string | `financial-modelling-fundamentals` | exact match |
| ~~`filters[category_id]`~~ / ~~`filters[trainer_id]`~~ | — | — | **do not use — see pitfalls above**; not usable with the category/trainer uuid the API returns |

Not scoped by visibility — admin sees every course regardless of `is_visible`/`content_status` (unlike the user-facing catalog).

**200 OK** (`CourseResource`):
```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": [
    {
      "id": "c1a2...-uuid",
      "slug": "financial-modelling-fundamentals",
      "name": "Financial Modelling Fundamentals",
      "description": "A practical course covering financial modelling from first principles.",
      "course_type": "recorded",
      "category": { "id": "cat-uuid", "name": "Finance" },
      "trainer": { "id": "trainer-uuid", "name": "Dr. Farooq Al-Bateel" },
      "has_quiz": true,
      "has_certificate": true,
      "content_status": "draft",
      "is_visible": false,
      "start_date": null,
      "max_seats": null,
      "image": "https://cdn.example.com/course.jpg",
      "demo_video": "https://cdn.example.com/demo.mp4",
      "created_at": "2026-09-14 07:54:11"
    }
  ]
}
```
`seats_filled` only appears (via `whenLoaded`/`when`) for `course_type: live` — the count of `active`+`completed` enrollments.

### `GET /admin/courses/{course}` — Detail

**Path variables:** `course` — the course's `uuid`.

Adds (`CourseDetailsResource`): `has_free_sample`, `video_completion_threshold_default`, `cert_required_quiz_pass_percent`, `cert_required_content_percent`, `training_timer_max_minutes`, `has_quiz_content` (derived from actual curriculum, never a mirror of `has_quiz`), `lessons_count`, `sessions_count`, all-locale text blocks (`demo_text`, `target_audience`, `requirements`, `goals`, `subscription_terms`, `certificate_terms` — current-locale flat values; the show pipeline also appends a full per-locale translation block for every configured locale, for populating an admin edit form), `prices[]` (`country_id` = country **uuid**, each row `{country_id, country_name, currency_code, currency_symbol, price, is_default}`), `updated_at`.

**404:** `{ "status": "fail", "message": "Data not found", "data": null }`

### `POST /admin/courses/recorded` — Create a recorded course

**Body**
```json
{
  "has_quiz": true,
  "video_completion_threshold_default": 80,
  "cert_required_quiz_pass_percent": 70,
  "category_id": "{{category_id}}",
  "trainer_id": "{{trainer_id}}",
  "has_free_sample": true,
  "has_certificate": true,
  "is_visible": true,
  "content_status": "draft",
  "image": "{{media_hash}}",
  "demo_video": "{{media_hash}}",
  "cert_required_content_percent": 80,
  "training_timer_max_minutes": 240,
  "prices": [{ "country_id": "{{country_id}}", "price": 49.99, "is_default": true }],
  "en": { "name": "Financial Modelling Fundamentals", "description": "...", "demo_text": "...", "target_audience": "...", "requirements": "...", "goals": "...", "subscription_terms": "...", "certificate_terms": "..." },
  "ar": { "name": "أساسيات النمذجة المالية", "description": "...", "demo_text": "...", "target_audience": "...", "requirements": "...", "goals": "...", "subscription_terms": "...", "certificate_terms": "..." }
}
```

| Field | Type | Required | Rules |
|---|---|---|---|
| `has_quiz` | boolean | no | `sometimes` — whether curriculum may include quizzes (trainer still adds them separately) |
| `video_completion_threshold_default` | integer | no | `between:1,100` — % watched to count a lesson as complete when a lesson has no override |
| `cert_required_quiz_pass_percent` | integer | no | `nullable, between:0,100` |
| `max_seats` | — | — | **prohibited** (live-only concept) |
| `category_id` / `trainer_id` | uuid | no | `exists:categories,uuid` / `exists:trainer_profiles,uuid` |
| `has_free_sample`, `has_certificate`, `is_visible` | boolean | no | — |
| `content_status` | enum | no | `draft`\|`pending_review`\|`needs_revision`\|`approved` |
| `image` | media hash | yes | image |
| `demo_video` | media hash | no | video |
| `cert_required_content_percent` | integer | no | `nullable, between:0,100` |
| `training_timer_max_minutes` | integer | no | applies to both course types (عداد التدريب cap) |
| `prices` | array | no | `sometimes, array` — at most **one** default (validated in `withValidator`; a course may legally have zero prices, unlike Book) |
| `prices.*.country_id` | uuid | conditional | `required_with:prices, distinct, exists:countries,uuid` |
| `prices.*.price` | number | conditional | `required_with:prices, numeric, min:0` |
| `prices.*.is_default` | boolean | no | `sometimes` |
| `{en,ar}.name` | string | yes | max 200 |
| `{en,ar}.description` | string | no | — |
| `{en,ar}.demo_text` | string | no | — |
| `{en,ar}.target_audience` | string | no | — |
| `{en,ar}.requirements` | string | no | — |
| `{en,ar}.goals` | string | no | — |
| `{en,ar}.subscription_terms` | string | no | — |
| `{en,ar}.certificate_terms` | string | no | — |

**201 Created** — `CourseDetailsResource` (same shape as the `GET .../{course}` example above).

**422 fail examples**
```json
{ "status": "fail", "message": "Only one price may be marked as the default.", "data": null }
{ "status": "fail", "message": "The image field is required.", "data": null }
{ "status": "fail", "message": "The has quiz field is prohibited.", "data": null }
```

### `PUT /admin/courses/recorded/{course}` — Update a recorded course

**Path variables:** `course`. Same fields, all `sometimes`. **422** if called on a live course:
```json
{ "status": "error", "message": "This course is recorded — use the recorded courses endpoint to update it.", "data": { "error_code": "BUSINESS_RULE_VIOLATED" } }
```
*(message reads "...is live..." when the mismatch is the other way, from `POST/PUT courses/live`)*

### `POST /admin/courses/live` — Create a live course

Same shared fields as recorded, except:

| Field | Type | Required | Rules |
|---|---|---|---|
| `max_seats` | integer | **yes** | `min:1` |
| `has_quiz`, `video_completion_threshold_default`, `cert_required_quiz_pass_percent` | — | — | **prohibited** — live courses carry no quizzes this phase |

All other shared fields (`category_id`, `trainer_id`, `has_free_sample`, `has_certificate`, `is_visible`, `content_status`, `image`, `demo_video`, `cert_required_content_percent`, `training_timer_max_minutes`, `prices[]`, `{en,ar}.*`) are identical to the recorded-course table above.

**Body**
```json
{
  "max_seats": 25,
  "category_id": "{{category_id}}",
  "trainer_id": "{{trainer_id}}",
  "has_free_sample": true,
  "has_certificate": true,
  "is_visible": true,
  "content_status": "draft",
  "image": "{{media_hash}}",
  "demo_video": "{{media_hash}}",
  "cert_required_content_percent": 80,
  "training_timer_max_minutes": 240,
  "prices": [{ "country_id": "{{country_id}}", "price": 49.99, "is_default": true }],
  "en": { "name": "Live Financial Modelling Bootcamp", "description": "..." },
  "ar": { "name": "معسكر النمذجة المالية المباشر", "description": "..." }
}
```

**422 — sending a recorded-only field**
```json
{ "status": "fail", "message": "The has quiz field is prohibited.", "data": null }
```

### `PUT /admin/courses/live/{course}` — Update a live course

**Path variables:** `course`. Same as create, `max_seats` becomes `sometimes`. **422** with "...is live — use the recorded courses endpoint..." wording if the target is actually recorded (message reversed from the recorded-update case above).

### `GET /admin/courses/{course}/publish-check` — Dry-run publish validation

**Path variables:** `course`. Never changes state. **200 OK:**
```json
{
  "status": "success",
  "message": "Publish validation completed",
  "data": { "publishable": false, "errors": ["curriculum_empty", "trainer_missing"] }
}
```
Recorded-course codes: `trainer_missing`, `curriculum_empty`, `invalid_threshold`, `quiz_without_questions`, `quiz_rule_without_quizzes`, `content_rule_without_videos`. Live-course codes: `trainer_missing`, `max_seats_required`, `no_sessions`, `invalid_session_duration`, `live_quiz_rule_forbidden`, `live_quizzes_forbidden`, `content_rule_required`.

### `POST /admin/courses/{course}/publish`

**Path variables:** `course`. No body. Re-runs the same checks; **422** with the same `errors` list (in `context`, debug-mode only) if any remain:
```json
{ "status": "error", "message": "This course is not ready to be published.", "data": { "error_code": "BUSINESS_RULE_VIOLATED" } }
```
On success: `content_status: approved`, `is_visible: true`. **200 OK** → `CourseDetailsResource`.

### `POST /admin/courses/{course}/unpublish`

**Path variables:** `course`. No body. Sets `is_visible: false` only — no validation, no `content_status` change. **200 OK** → `CourseDetailsResource`.

### `POST /admin/courses/{course}/approve`

**Path variables:** `course`. No body. `pending_review → approved`. **422** from any other status:
```json
{ "status": "error", "message": "A course in this state cannot move to approved.", "data": { "error_code": "INVALID_STATE_TRANSITION" } }
```

### `POST /admin/courses/{course}/request-revision`

**Path variables:** `course`. No body. `pending_review → needs_revision`. Same `INVALID_STATE_TRANSITION` guard from any other status.

### `GET /admin/courses/{course}/completion-summary`

**Path variables:** `course`. No query parameters. **200 OK:**
```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "course_id": "c1a2...-uuid",
    "total_enrolled": 42,
    "average_progress_percent": 63.5,
    "completed": 11,
    "completion_rate": 26.19,
    "sessions_total": 8,
    "average_attendance_rate": 71.43
  }
}
```
`sessions_total` / `average_attendance_rate` appear only for `course_type: live`.

### `DELETE /admin/courses/{course}` — Delete

**Path variables:** `course`. No course-specific business-rule guard (unlike books) — `beforeDestroy()` is not overridden. **200 OK:** `{ "status": "success", "message": "Deleted successfully", "data": [] }`.

### `DELETE /admin/courses/bulk-delete` — Bulk delete

**Body:** `{ "ids": ["{{course_id}}", "{{course_id_2}}"] }` — array of course **uuid**s, same no-guard behavior per id, one transaction.

---

## Curriculum (read-only for admin — no pagination/search/filter on either endpoint)

### `GET /admin/courses/{course}/lessons` — Curriculum in presentation order

**Path variables:** `course`. No query parameters — always the full, unpaginated list ordered by `sort_order`.

**200 OK** (`LessonResource[]`):
```json
{
  "status": "success",
  "message": "Curriculum retrieved successfully",
  "data": [
    { "id": "lesson-uuid", "type": "lesson", "title": "Building the income statement", "description": "...", "sort_order": 0, "duration_seconds": 720, "completion_threshold": 90, "video": "https://cdn.example.com/lesson.mp4" },
    { "id": "quiz-uuid", "type": "quiz", "title": "Module 1 quiz", "description": null, "sort_order": 1, "quiz_duration_minutes": 15, "max_attempts": 3, "pass_score": 70, "must_pass_to_continue": true, "show_result_immediately": true, "questions_count": 5 }
  ]
}
```

### `GET /admin/lessons/{lesson}` — Lesson/quiz detail with all-locale text and full quiz questions

**Path variables:** `lesson` — the lesson's `uuid`. Adds `en`/`ar` `{title, description}` blocks, and for a quiz, `questions[]` with each question's `{id, question_type, question_text, score, sort_order, en, ar, options[]}` — **`options[].is_correct` is included** (admin-only view — never exposed to learners, see [courses-user.md](./courses-user.md)).

---

## Live sessions

### `GET /admin/courses/{course}/sessions` — List, ordered by `sort_order`

**Path variables:** `course`. No query parameters — always the full, unpaginated list.

**200 OK** (`ClassSessionResource[]`):
```json
{ "id": "sess-uuid", "mode": "online", "session_date": null, "start_time": null, "duration_minutes": 90, "meeting_link": null, "sort_order": 0, "is_scheduled": false, "can_join": false }
```
`is_scheduled` is `false` (Phase A placeholder — "تحدد لاحقاً") until both `session_date` and `start_time` are set; `can_join` additionally requires a `meeting_link`.

### `POST /admin/courses/{course}/sessions` — Phase A: create placeholders

**Path variables:** `course`. **Body:** `{ "count": 4, "duration_minutes": 90, "mode": "online" }`

| Field | Type | Required | Rules |
|---|---|---|---|
| `count` | integer | yes | `min:1, max:500` — number of placeholder sessions to create in one call |
| `duration_minutes` | integer | yes | `min:1` |
| `mode` | enum | no | `online`\|`onsite`, default `online` |

**201 Created** — the new `ClassSessionResource[]` (only the newly created ones, not the full list).

### `PUT /admin/sessions/{session}` — Phase B: attach the calendar slot

**Path variables:** `session`. **Body:** `{ "session_date": "2026-10-05", "start_time": "18:00:00", "meeting_link": "https://meet.example.com/abc" }`

| Field | Type | Required | Rules |
|---|---|---|---|
| `session_date` | date | yes | `date_format:Y-m-d` |
| `start_time` | time | yes | `date_format:H:i:s` |
| `meeting_link` | url | no | `nullable, url, max:2048` — omitted keeps the session's existing link |

Recomputes `courses.start_date` as `MIN(session_date)` in the same transaction. **200 OK** → `ClassSessionResource`.

### `POST /admin/sessions/{session}/unschedule` — Back to Phase A

**Path variables:** `session`. No body. Clears `session_date`/`start_time`; recomputes `start_date`.

### `DELETE /admin/sessions/{session}` — Delete a session

**Path variables:** `session`. No body. Recomputes `start_date` afterwards.

**200 OK:** `{ "status": "success", "message": "Session deleted successfully", "data": [] }`

---

## Enrollments

### `GET /admin/enrollments/users` and `GET /admin/enrolled-users` — Same controller/service, two route names

**Query parameters**

| Key | Type | Example | Notes |
|---|---|---|---|
| `course_id` | uuid or int | `{{course_id}}` | required unless `program_id` given; `prohibits` `program_id`; matched against **either** `uuid` or internal `id` |
| `program_id` | uuid or int | `{{program_id}}` | required unless `course_id` given; `prohibits` `course_id` |
| `search` | string | `sara` | matches the enrolled user's `full_name` LIKE, `email` LIKE, or `phone` LIKE (OR'd) |
| `status` | string | `active` | exact match on `enrollments.status` (`active`\|`completed`\|… depending on domain) |
| `is_active` | boolean | `true` | filters by the enrolled **user's** `is_active` column, not the enrollment's |
| `from` / `to` | date | `2026-01-01` | inclusive bounds on `enrolled_at` |
| `per_page` | integer | `15` | used only when paginating (default path) |
| `paginate` | `false`\|`0` | `false` | switch to the unpaginated path, capped at **500** rows, plain array response |

**404 (plain `fail`, not a domain error)** if the `course_id`/`program_id` doesn't resolve:
```json
{ "status": "fail", "message": "Course not found.", "data": null }
```
*(or `"Training program not found."` for `program_id`)*

**200 OK** (`EnrolledUserResource`, paginated response wrapped as `{"enrollments": [...]}` when paginated):
```json
{
  "id": "enr-uuid",
  "status": "active",
  "progress_percent": 63.5,
  "item_type": "course",
  "user": { "id": "u-uuid", "full_name": "Sara Ahmed", "email": "sara@example.com", "phone_code": "+20", "phone": "1001234567", "image": "...", "user_type": "User", "is_active": true, "is_ban": false },
  "course": { "id": "c1a2...-uuid", "name": "Financial Modelling Fundamentals" },
  "program": null,
  "enrolled_at": "2026-09-01 10:00:00",
  "completed_at": null,
  "created_at": "2026-09-01 10:00:00"
}
```
