> ## Documentation Index
> Fetch the complete documentation index at: https://theteam-13fc7f49.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Chapters & Lessons Export / Import

Export chapters (with their lessons and all their chapter quizzes) as JSON, download every chapter of a training as a ZIP, import chapters from JSON files, and bulk-import lessons — either uploading their attached images to Cloudflare R2 or to the local Laravel storage.

All endpoints are authenticated — they run behind `auth:sanctum`, the "user is not blocked" check, and the per-company policy middleware. Always send your Sanctum token as a `Bearer` header.

## Table of contents

* **Base URL & Headers**
* **Export a single chapter**
* **Export all chapters of a training (ZIP)**
* **Import chapters from JSON**
* **Bulk-import lessons to Cloudflare R2**
* **Bulk-import lessons to local storage (deprecated 05-2026)**
* **Import behavior at a glance**
* **Postman setup**

***

## Base URL & Headers

```shellscript theme={null}
Base URL: {{base_url}}/api

Accept: application/json
Authorization: Bearer {{token}}
```

> The lesson-import endpoints use `multipart/form-data`. Do **not** set an explicit `Content-Type` header for those requests — let Postman (or your HTTP client) build the multipart boundary itself.

### The chapter export schema

Every per-chapter JSON produced by the export endpoints follows this shape (all relations embedded):

```json theme={null}
{
  "id": 40,
  "training_id": 12,
  "label": "Chapter 1 — Fundamentals",
  "position": 1,
  "created_at": "2026-04-01 09:22:10",
  "updated_at": "2026-04-10 14:03:55",
  "archived_at": null,
  "deleted_at": null,
  "training": {
    "id": 12,
    "label": "Intro to PMP",
    "slug": "intro-to-pmp"
  },
  "lessons": [
    {
      "id": 101,
      "chapter_id": 40,
      "label": "Lesson 1",
      "type": "document",
      "position": 1,
      "content": "<p>...</p>",
      "created_at": "2026-04-01 09:25:00",
      "updated_at": "2026-04-01 09:25:00"
    }
  ],
  "quizzes": [
    {
      "id": 77,
      "company_id": 1,
      "training_id": null,
      "chapter_id": 40,
      "instructor_id": 5,
      "label": "Chapter 1 Quiz",
      "slug": "chapter-1-quiz",
      "description": "Check the fundamentals.",
      "max_attempts": 3,
      "passing_percentage": 70,
      "duration": 15,
      "is_published": 1,
      "revenue_percentage": 0,
      "average_rating": 0,
      "total_ratings": 0,
      "total_sales": 0,
      "final_price": 0,
      "total_active_sales": 0,
      "instructor": { "id": 5, "first_name": "Ali", "last_name": "Ben" },
      "quiz_questions": [
        {
          "id": 300,
          "quiz_id": 77,
          "quiz_question_type_id": 2,
          "label": "What does the project scope define?",
          "tip": null,
          "explanation": "Scope = the work required to deliver the result.",
          "quiz_question_options": [
            {
              "id": 900,
              "quiz_question_id": 300,
              "label": "The work to be done",
              "is_correct": true,
              "quiz_question_option_items": []
            }
          ]
        }
      ]
    },
    {
      "id": 78,
      "training_id": null,
      "chapter_id": 40,
      "label": "Chapter 1 Practice Quiz",
      "slug": "chapter-1-practice-quiz",
      "is_published": 0,
      "quiz_questions": []
    }
  ],
  "quiz": {
    "id": 77,
    "chapter_id": 40,
    "label": "Chapter 1 Quiz"
  }
}
```

The chapter quizzes are exported in two keys (since 19/10/2026, when a chapter started holding several quizzes):

* `quizzes` — **every** quiz of the chapter (published **and** draft), in creation order, each with its full question tree (questions → options with `is_correct` → drag-and-drop items). `[]` when the chapter has none.
* `quiz` — ⚠️ **deprecated**: the same object as `quizzes[0]` (shortened above), or `null`. Kept for older readers of export files. Files exported before 19/10/2026 only have `quiz`.

The file is the full chapter record (`Chapter::toArray()`), so each quiz also carries its timestamps and the other columns of the `quizzes` table.

***

## Export a single chapter

```shellscript theme={null}
GET /chapter/{id}/export
```

Downloads one chapter as a `.json` file. The file includes the chapter's training metadata, all lessons, and **every** quiz of the chapter with its question tree (`quizzes`, plus the deprecated `quiz` = the first one).

### Query parameters

| Name | Type | Required | Description |
| :- | :- | :- | :- |
| `company_id` | integer | Yes | Must exist in `companies`. The chapter's training must belong to it. |
| `training_id` | integer | Yes | Must exist in `trainings`. The chapter must belong to this training. |

If either filter doesn't match, the response is `404`.

### Example request

```shellscript theme={null}
GET {{base_url}}/api/chapter/40/export?company_id=1&training_id=12
Accept: application/json
Authorization: Bearer {{token}}
```

> In Postman, click **Send and Download**. The filename pattern is `{training-slug}-chapter-{chapter-slug}-{dd-mm-YYYY-HH-mm}.json`.

### Responses

`200 OK` — file body follows [the chapter export schema](#the-chapter-export-schema).

`404 Not Found` — chapter does not match the supplied company / training:

```json theme={null}
{ "message": "No query results for model [App\\Models\\Chapter]." }
```

`422 Unprocessable Entity` — missing query parameters:

```json theme={null}
{
  "message": "The given data was invalid.",
  "errors": {
    "company_id": ["The company id field is required."],
    "training_id": ["The training id field is required."]
  }
}
```

***

## Export all chapters of a training (ZIP)

`GET /chapters/export-all`

Downloads a ZIP archive containing one JSON file per chapter for the given training.

### Query parameters

| Name | Type | Required | Description |
| :- | :- | :- | :- |
| `company_id` | integer | Yes | |
| `training_id` | integer | Yes | |

### Example request

```shellscript theme={null}
GET {{base_url}}/api/chapters/export-all?company_id=1&training_id=12
Accept: application/json
Authorization: Bearer {{token}}
```

### ZIP layout

```shellscript theme={null}
ALL_chapters_export_17-04-2026-12-30.zip
├── intro-to-pmp-chapter-fundamentals-17-04-2026-12-30.json
├── intro-to-pmp-chapter-planning-17-04-2026-12-30.json
└── intro-to-pmp-chapter-execution-17-04-2026-12-30.json
```

Each JSON file follows [the chapter export schema](#the-chapter-export-schema), so every chapter file carries all its quizzes in `quizzes`.

### Responses

`404 Not Found` — no chapters for the given training:

```json theme={null}
{ "message": "No chapters found for the given training." }
```

`500 Internal Server Error` — ZIP creation failed:

```json theme={null}
{ "message": "Failed to create ZIP archive." }
```

***

## Import chapters from JSON

```shellscript theme={null}
POST /chapters/import
```

Creates one or more chapters from uploaded `.json` files. Lessons found inside the JSON payload are imported along with each chapter. **Quizzes are not imported by this endpoint**: the `quizzes` array (and the older `quiz` key) of an export file is ignored, so an imported chapter starts without quizzes. Recreate them with the [Quizzes & Exams endpoints](https://theteam-13fc7f49.mintlify.site/api-reference/quizzes-and-exams-endpoints.md) (`chapter_id` = the new chapter id from the response).

### Form-data fields

| Name | Type | Required | Description |
| :- | :- | :- | :- |
| `company_id` | integer | Yes | Must exist in `companies`. Owns the target training. |
| `training_id` | integer | Yes | Must exist in `trainings` and belong to `company_id`. |
| `chapter_file` | file | \* | A single `.json` file. Required if `chapter_files[]` is not sent. |
| `chapter_files[]` | file\[] | \* | One or more `.json` files. Required if `chapter_file` is not sent. |

### Per-file JSON validation

* `label` — optional (defaults to `"Untitled Chapter"`).
* `position` — ignored on import. The new chapter is placed at `max(position) + 1` within the target training.
* `lessons` — optional array. Each item may include `label` (defaults to `"Untitled Lesson"`), `type` (`document` or `video`, defaults to `document`; unknown values fall back to `document`), `content` (defaults to `""`), and `position` (defaults to `0`).
* `quizzes` / `quiz` — **not imported** by this endpoint (ignored when present). The unused quiz-import helper that sat in the controller was removed on 19/10/2026; nothing calls a quiz import from here.

### Example file payload

`intro-to-pmp-chapter-1.json`

```json theme={null}
{
  "label": "Chapter 1 — Fundamentals",
  "lessons": [
    {
      "label": "Lesson 1 — Welcome",
      "type": "video",
      "content": "<p>Welcome.</p>",
      "position": 1
    },
    {
      "label": "Lesson 2 — Setup",
      "content": "<p>Install everything.</p>",
      "position": 2
    }
  ]
}
```

### Postman setup (Body → form-data)

Multi-file upload:

```shellscript theme={null}
Key                | Type | Value
-------------------+------+---------------------------------
company_id         | Text | 1
training_id        | Text | 12
chapter_files[]    | File | intro-to-pmp-chapter-1.json
chapter_files[]    | File | intro-to-pmp-chapter-2.json
```

Single-file upload:

```shellscript theme={null}
Key             | Type | Value
----------------+------+---------------------------------
company_id      | Text | 1
training_id     | Text | 12
chapter_file    | File | intro-to-pmp-chapter-1.json
```

### Responses

`200 OK` — all files succeeded:

```json theme={null}
{
  "imported_chapters": [
    {
      "file_name": "intro-to-pmp-chapter-1.json",
      "chapter_id": 120,
      "message": "Chapter imported successfully."
    },
    {
      "file_name": "intro-to-pmp-chapter-2.json",
      "chapter_id": 121,
      "message": "Chapter imported successfully."
    }
  ],
  "failed_chapters": []
}
```

`200 OK` — partial success. Always inspect `failed_chapters`:

```json theme={null}
{
  "imported_chapters": [
    {
      "file_name": "intro-to-pmp-chapter-1.json",
      "chapter_id": 120,
      "message": "Chapter imported successfully."
    }
  ],
  "failed_chapters": [
    {
      "file_name": "broken.json",
      "error": "Invalid JSON structure."
    }
  ]
}
```

`400 Bad Request` — nothing uploaded:

```json theme={null}
{ "message": "No chapter files provided." }
```

***

## Bulk-import lessons to Cloudflare R2

`POST /lessons/import_to_r2`

Uploads a batch of image files to Cloudflare R2, creates (or reuses) a chapter, and generates one lesson per uploaded file with an `<img>` tag pointing at the R2 URL.

Use this when you want lesson images served from the R2 CDN.

### Form-data fields

| Name | Type | Required | Description |
| :- | :- | :- | :- |
| `training_id` | integer | Yes | Must exist in `trainings`. |
| `company_id` | integer | Yes | Must exist in `companies`. |
| `chapter_label` | string | No | Label of the chapter to upload into. Defaults to `"imported Chapter X"`. Matched via `firstOrCreate` against `training_id + label`. |
| `folder_name` | string | No | **Ignored since 01/11/2026** (legacy). The folder is always `files/all-lessons/{training-slug}/bulk/`, built by the backend from `training_id` (`app/Services/R2StoragePathService.php`). Before that date the files landed in `files/{folder_name}/`. |
| `files` | file\[] | Yes | Array of files. One lesson is created per file. |

> [!NOTE]
>
> The training must belong to `company_id` (a super-admin may name any company's), else `422`. The answer
> carries `folder`, the bucket folder the images went to; the Flutter import modal shows it in its success
> dialog. The whole layout is on [R2 Storage Paths](https://theteam-13fc7f49.mintlify.site/api-reference/r2-storage-paths.md).

### Behavior notes

* **Lesson order & labels.** Lessons are created in the order the files are sent. The first file becomes `Lesson 1`, the second `Lesson 2`, and so on.
* **Lesson content.** Each lesson's `content` is set to `<p style="text-align: center;"><img src="{R2 URL}" alt=""></p>`.
* **R2 visibility.** Files are uploaded with `visibility: public` so the generated URLs are directly accessible.
* **Lesson type.** Every imported lesson is a `document` (one image per lesson); switch a lesson to `video` afterwards with `POST /lesson/{id}`.
* **Transactional.** The whole operation runs inside a DB transaction — if any insert fails, both the `files` and `lessons` rows are rolled back (the R2 object is **not** rolled back).

### Postman setup (Body → form-data)

```shellscript theme={null}
Key              | Type | Value
-----------------+------+---------------------------------
training_id      | Text | 12
company_id       | Text | 1
chapter_label    | Text | Chapter 1 — Slides
files            | File | slide-01.png
files            | File | slide-02.png
files            | File | slide-03.png
```

> Repeat the `files` key for every file; don't use `files[]` with this endpoint — the controller reads `$request->file('files')`.

### Response — `200 OK`

```json theme={null}
{
  "message": "Lessons imported successfully.",
  "folder": "files/all-lessons/pmp-foundations/bulk",
  "chapter": {
    "id": 120,
    "training_id": 12,
    "label": "Chapter 1 — Slides",
    "position": 3,
    "created_at": "2026-04-17 12:30:00",
    "updated_at": "2026-04-17 12:30:00"
  },
  "lessons": [
    {
      "id": 500,
      "chapter_id": 120,
      "label": "Lesson 1",
      "position": 1,
      "content": "<p style=\"text-align: center;\"><img src=\"https://pub-xxx.r2.dev/files/all-lessons/pmp-foundations/bulk/17-04-2026-12-30-slide-01.png\" alt=\"\"></p>"
    },
    {
      "id": 501,
      "chapter_id": 120,
      "label": "Lesson 2",
      "position": 2,
      "content": "<p style=\"text-align: center;\"><img src=\"https://pub-xxx.r2.dev/files/all-lessons/pmp-foundations/bulk/17-04-2026-12-30-slide-02.png\" alt=\"\"></p>"
    }
  ]
}
```

### Error responses

`422 Unprocessable Entity` — validation error (missing field, or a training of another company):

```json theme={null}
{ "message": "Validation Error - The training does not exist or does not belong to your company." }
```

`500 Internal Server Error` — R2 (S3) upload failed:

```json theme={null}
{ "message": "Unable to upload the files to Cloudflare R2." }
```

`500 Internal Server Error` — unexpected error:

```json theme={null}
{ "message": "An unexpected error occurred. Please contact your platform administrator." }
```

***

## Bulk-import lessons to local storage (deprecated 05-2026)

This feature was removed from the UI (May-2026), preferably to use this

```shellscript theme={null}
POST /lessons/import_to_storage
```

Same behavior as the R2 variant above, except files are written to the local Laravel `public` disk (under `storage/app/public/files/{folder}`) and the URLs are served from `storage/...` via the public symlink.

Use this when you prefer self-hosted media, or when you're working in a dev environment without R2 credentials.

### Form-data fields

Identical to **the R2 variant**:

| Name | Type | Required | Description |
| :- | :- | :- | :- |
| `training_id` | integer | Yes | |
| `company_id` | integer | Yes | |
| `chapter_label` | string | No | Defaults to `"imported Chapter X"`. |
| `folder_name` | string | No | Folder under `storage/app/public/files/`. Defaults to `{training-slug}-{dd-mm-YYYY-HH-mm}`. |
| `files` | file\[] | Yes | Array of files. One lesson is created per file. |

### Behavior notes

* Internally this endpoint delegates file persistence to `FilesController@bulk_upload_to_storage`. Each file is stored at `files/{folder}/{dd-mm-YYYY-HH-mm}-{slugified-name}.{ext}`.
* Each lesson's `content` is set to `<p style="text-align: center;"><img src="{public URL}" alt=""></p>`, the same markup as the R2 variant.
* The chapter is created (or reused) via `firstOrCreate` on `training_id + label`.
* The whole operation is wrapped in a DB transaction.

### Postman setup (Body → form-data)

```text theme={null}
Key              | Type | Value
-----------------+------+---------------------------------
training_id      | Text | 12
company_id       | Text | 1
chapter_label    | Text | Chapter 1 — Slides
folder_name      | Text | pmp-chapter-1
files            | File | slide-01.png
files            | File | slide-02.png
```

### Response — `200 OK`

```text theme={null}
{
  "message": "Lessons imported successfully.",
  "chapter": {
    "id": 120,
    "training_id": 12,
    "label": "Chapter 1 — Slides",
    "position": 3
  },
  "lessons": [
    {
      "id": 500,
      "chapter_id": 120,
      "label": "Lesson 1",
      "position": 1,
      "content": "<p style=\"text-align: center;\"><img src=\"https://your-app.test/storage/files/pmp-chapter-1/17-04-2026-12-30-slide-01.png\" alt=\"\"></p>"
    }
  ]
}
```

### Error responses

`422 Unprocessable Entity`:

```json theme={null}
{ "message": "Validation Error - The files field is required." }
```

`500 Internal Server Error` — underlying bulk upload failed:

```json theme={null}
{ "message": "An error occurred during the import process. - <details>" }
```

***

## Import behavior at a glance

| Aspect | Behavior |
| :- | :- |
| Quizzes (chapter or lesson import) | Not imported by any endpoint on this page: `quizzes` / `quiz` in a chapter file are ignored. Recreate them with the [Quizzes & Exams endpoints](https://theteam-13fc7f49.mintlify.site/api-reference/quizzes-and-exams-endpoints.md). |
| Chapter position on JSON import | Ignored. Chapters are appended at `max(position) + 1` within the target training. |
| Lesson position on JSON import | Honored from the JSON payload (defaults to `0` when missing). |
| Chapter label fallback | `"Untitled Chapter"` when missing. |
| Lesson label fallback | `"Untitled Lesson"` when missing. |
| Lesson label on bulk image import | Auto-generated as `Lesson 1`, `Lesson 2`, … in file-upload order. |
| Lesson content on bulk image import | Always an `<img>` wrapper. Plain-text content must be added via a separate update call. |
| Folder name on bulk image import | Defaults to `{training-slug}-{dd-mm-YYYY-HH-mm}`. Provided values are slug-normalized. |
| Chapter reuse on bulk image import | `firstOrCreate` on `training_id + chapter_label` — existing chapters are appended to, not duplicated. |
| Transactions | Chapter JSON import: one per file. Bulk image import: single transaction per request. |

***

## Postman setup

1. **Environment variables.**
   * `base_url` — your API root, e.g. `http://localhost:8000`
   * `token` — the Sanctum token returned by `POST /auth/login`
2. **Collection-level headers.**
   * `Accept: application/json`
   * `Authorization: Bearer {{token}}` Do **not** set a `Content-Type` header on any of the import endpoints on this page — they all use `multipart/form-data` and need Postman to generate the boundary.
3. **Downloads.** For `export` and `export-all`, click **Send and Download** so Postman writes the file to disk.
4. **Uploads — file key naming.**
   * Chapter JSON import: `chapter_file` (single) or `chapter_files[]` (multiple).
   * Lesson bulk import (both R2 and local): `files` repeated per file — **not** `files[]`.
5. **Smoke-test flow.**
   1. `POST /chapters/import` with a single JSON file
   2. `GET /chapter/{id}/export?company_id=...&training_id=...` — round-trip check
   3. `POST /lessons/import_to_storage` with a few PNGs — confirm one lesson per image
   4. `GET /lessons?chapter_id={id}` — verify lessons were created
   5. `GET /chapters/export-all?company_id=...&training_id=...` — download the full ZIP


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.