> ## 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.

# Instructor  (Dashboard) Endpoints (v4)

###### ***Instructor API and Dashboard Guide***

This file documents the instructor-facing routes and the instructor dashboard/reporting endpoints.

## **Table of Contents**

1. **Overview**
2. **Base URL and Headers**
3. **List Instructors**
4. **Get Instructor by ID**
5. **Trainings by Instructor**
6. **Exams by Instructor**
7. **Quizzes by Instructor**
8. **Set Instructor Default Revenue**
9. **Dashboard Overview**
10. **Monthly Sales**
11. **Weekly Sales**
12. **Combined Weekly and Monthly Sales**
13. **Instructor Earnings Route**
14. **Sales Details Summary**
15. **Sales Details Orders**
16. **Export Dashboard PDF**
17. **Export Dashboard CSV**
18. **Internal Reused Methods**
19. **Known Implementation Notes**
20. **Background Items API**
    * **20.1 List Background Items for a User**
    * **20.2 Create Background Item**
    * **20.3 Show Background Item**
    * **20.4 Update Background Item**
    * **20.5 Delete Background Item**

***

## **1. Overview**

This file covers:

* instructor discovery and profile lookup
* instructor-owned trainings and exams
* default revenue percentage management
* instructor sales dashboard endpoints
* instructor sales detail endpoints
* instructor dashboard exports

***

## **2. Base URL and Headers**

Base API URL:

```json theme={null}
{{base_url}}/api
```

Suggested Postman variables:

```json theme={null}
{{base_url}} = http://127.0.0.1:8000
{{token}} = your_sanctum_token
```

Headers:

```shellscript theme={null}
Accept: application/json
Content-Type: application/json
Authorization: Bearer {{token}}
```

For export endpoints, use:

```shellscript theme={null}
Accept: application/pdf
```

or:

```shellscript theme={null}
Accept: text/csv
```

***

## **3. List Instructors**

Returns paginated instructors for a company.

| **Field** | **Value** |
| :- | :- |
| Method | `GET` |
| URL | `/api/instructors` |
| Auth | Bearer Token |

### **Example Request**

```text theme={null}
GET /api/instructors?company_id=1&search=jane&page=1&per_page=10
Authorization: Bearer {{token}}
Accept: application/json
```

### **Success Response**

```text theme={null}
{
  "current_page": 1,
  "data": [
    {
      "id": 7,
      "company_id": 1,
      "role_id": 2,
      "first_name": "Jane",
      "last_name": "Doe",
      "image": null,
      "trainings_as_instructor": [],
      "exams_as_instructor": []
    }
  ],
  "per_page": 10,
  "total": 1
}
```

***

## **4. Get Instructor by ID**

Returns one instructor from the authenticated user's company.

| **Field** | **Value** |
| :- | :- |
| Method | `GET` |
| URL | `/api/instructor/{id}` |
| Auth | Bearer Token |

### **Example Request**

```text theme={null}
GET /api/instructor/7
Authorization: Bearer {{token}}
Accept: application/json
```

### **Success Response**

```text theme={null}
{
  "id": 7,
  "company_id": 1,
  "role_id": 2,
  "first_name": "Jane",
  "last_name": "Doe",
  "image": null,
  "background_items": []
}
```

***

## **5. Trainings by Instructor**

Returns paginated trainings created by the instructor.

| **Field** | **Value** |
| :- | :- |
| Method | `GET` |
| URL | `/api/user/{instructor_id}/trainings_by_instructor` |
| Auth | Bearer Token |

### **Example Request**

```text theme={null}
GET /api/user/7/trainings_by_instructor?search=ai&page=1&per_page=10
Authorization: Bearer {{token}}
Accept: application/json
```

### **Success Response**

```text theme={null}
{
  "current_page": 1,
  "data": [
    {
      "id": 101,
      "label": "AI for Product Managers",
      "instructor_id": 7,
      "categories": [],
      "tags": [],
      "instructor": {
        "id": 7,
        "first_name": "Jane",
        "last_name": "Doe"
      },
      "chapters": [],
      "reviews": [],
      "image": null,
      "video": null
    }
  ],
  "per_page": 10,
  "total": 1
}
```

***

## **6. Exams by Instructor**

Returns standalone exams created by the instructor.

| **Field** | **Value** |
| :- | :- |
| Method | `GET` |
| URL | `/api/instructors/{instructor_id}/exams` |
| Auth | Bearer Token |

### **Example Request**

```text theme={null}
GET /api/instructors/7/exams
Authorization: Bearer {{token}}
Accept: application/json
```

### **Success Response**

```text theme={null}
[
  {
    "id": 88,
    "label": "AI Fundamentals Final Exam",
    "training_id": null,
    "chapter_id": null,
    "categories": [],
    "reviews": [],
    "image": null,
    "orders": [],
    "quiz_questions": []
  }
]
```

***

## **7. Quizzes by Instructor**

> **v4 — Implemented.** This route now resolves to `UsersController::quizzes_by_instructor()`.

Returns the **quizzes** created by the instructor — i.e. a `Quiz` that is linked to a training and/or a chapter (`training_id` and/or `chapter_id` is set). This is the complement of **Exams by Instructor** (§6), which returns the standalone exams (both FKs null). Each quiz is returned with its categories, reviews, image, purchasing orders, and full question/option tree.

> **Answer data is stripped** (hidden only on this listing — the quiz-authoring endpoints still expose it):
>
> * **All question types:** the `is_correct` flag is removed from every option, so the payload never reveals which choice is correct.
> * **Drag & Drop (type 4):** the correct answer is encoded in *which items belong to which option*. To obscure it, the per-option item lists (`quiz_question_option_items`) are removed and all items are flattened into a single **shuffled** pool exposed on the question as `draggable_items` (each with `id` + `label`, but no `quiz_question_option_id` back-reference). The options (drop targets) are still returned with their `id`/`label`; the player submits the grouping back via the finish-quiz endpoint.

| **Field** | **Value** |
| :- | :- |
| Method | `GET` |
| URL | `/api/instructors/{instructor_id}/quizzes` |
| Auth | Bearer Token |

### **Example Request**

```text theme={null}
GET /api/instructors/7/quizzes
Authorization: Bearer {{token}}
Accept: application/json
```

### **Success Response**

```text theme={null}
[
  {
    "id": 142,
    "label": "Chapter 1 Knowledge Check",
    "training_id": 101,
    "chapter_id": 55,
    "categories": [],
    "reviews": [],
    "image": null,
    "orders": [],
    "quiz_questions": []
  }
]
```

> Returns `[]` (empty array) when the instructor has no training/chapter-linked quizzes.

### **Drag & Drop question shape (answer obscured)**

For a `quiz_question_type_id = 4` (Drag & Drop) question, the options keep only their `id`/`label` (drop targets), the per-option item lists are removed, and the draggable items are returned flattened + shuffled under `draggable_items`:

```text theme={null}
{
  "id": 9001,
  "label": "Match each capital to its country",
  "quiz_question_type_id": 4,
  "quiz_question_options": [
    { "id": 71, "label": "France" },
    { "id": 72, "label": "Japan" }
  ],
  "draggable_items": [
    { "id": 504, "label": "Tokyo" },
    { "id": 501, "label": "Paris" }
  ]
}
```

> The `draggable_items` order is randomized per request, and no item carries a `quiz_question_option_id`, so the correct grouping cannot be inferred from the payload.

***

## **8. Set Instructor Default Revenue**

Updates the instructor's default revenue percentage.

| **Field** | **Value** |
| :- | :- |
| Method | `POST` |
| URL | `/api/user/{instructor_id}/set-instructor-default-revenue` |
| Auth | Bearer Token |

### **Request JSON**

```text theme={null}
{
  "default_revenue_percentage": 30
}
```

### **Success Response**

```text theme={null}
{
  "message": "Default revenue percentage updated successfully.",
  "default_revenue_percentage": 30
}
```

***

## **9. Dashboard Overview**

Returns high-level revenue and sales metrics for the instructor.

| **Field** | **Value** |
| :- | :- |
| Method | `GET` |
| URL | `/api/instructor/{id}/overview` |
| Auth | Bearer Token |

### **Example Request**

```text theme={null}
GET /api/instructor/7/overview
Authorization: Bearer {{token}}
Accept: application/json
```

### **Success Response**

```text theme={null}
{
  "total_orders_count": 14,
  "total_revenue": 1240.5,
  "total_instructor_share": 372.15,
  "avg_instructor_percentage": 30,
  "trainings_revenue": 980.5,
  "exams_revenue": 260,
  "num_orders_trainings": 10,
  "num_orders_exams": 4,
  "top_selling_trainings": [
    {
      "id": 101,
      "label": "AI for Product Managers",
      "sales_count": 8,
      "total_revenue": 780.5,
      "image": null
    }
  ],
  "top_selling_exams": [
    {
      "id": 88,
      "label": "AI Fundamentals Final Exam",
      "sales_count": 4,
      "total_revenue": 260
    }
  ],
  "total_sales_earnings_on_trainings": 294.15,
  "total_sales_earnings_on_exams": 78,
  "total_sales_count": 14
}
```

***

## **10. Monthly Sales**

Returns month-based sales aggregates for one year.

| **Field** | **Value** |
| :- | :- |
| Method | `GET` |
| URL | `/api/instructor/{id}/monthly-sales` |
| Auth | Bearer Token |

### **Query Parameters**

| **Name** | **Required** | **Notes** |
| :- | :- | :- |
| `year` | No | default is current year |

### **Example Request**

```text theme={null}
GET /api/instructor/7/monthly-sales?year=2026
Authorization: Bearer {{token}}
Accept: application/json
```

### **Success Response**

```text theme={null}
{
  "year": 2026,
  "monthly_stats": {
    "1": {
      "total_revenue": 0,
      "instructor_share": 0,
      "sales_count": 0,
      "total_trainings_sales": 0,
      "total_exams_sales": 0
    },
    "2": {
      "total_revenue": 1.240,
      "instructor_share": 0.372,
      "sales_count": 14,
      "total_trainings_sales": 0.980,
      "total_exams_sales": 0.260
    }
  }
}
```

Note:

* this endpoint divides the numeric values by `1000` before returning them

***

## **11. Weekly Sales**

Returns week-based sales aggregates for one year.

| **Field** | **Value** |
| :- | :- |
| Method | `GET` |
| URL | `/api/instructor/{id}/weekly-sales` |
| Auth | Bearer Token |

### **Example Request**

```text theme={null}
GET /api/instructor/7/weekly-sales?year=2026
Authorization: Bearer {{token}}
Accept: application/json
```

### **Success Response**

```text theme={null}
{
  "1": {
    "total_revenue": 0,
    "instructor_share": 0,
    "sales_count": 0,
    "total_trainings_sales": 0,
    "total_exams_sales": 0
  },
  "2": {
    "total_revenue": 260,
    "instructor_share": 78,
    "sales_count": 4,
    "total_trainings_sales": 0,
    "total_exams_sales": 260
  }
}
```

***

## **12. Combined Weekly and Monthly Sales**

Returns both monthly and weekly sales data in one payload.

| **Field** | **Value** |
| :- | :- |
| Method | `GET` |
| URL | `/api/instructor/{instructor_id}/sales-stats` |
| Auth | Bearer Token |

### **Example Request**

```text theme={null}
GET /api/instructor/7/sales-stats?year=2026
Authorization: Bearer {{token}}
Accept: application/json
```

### **Success Response**

```text theme={null}
{
  "year": 2026,
  "monthly_earnings": {
    "1": {
      "total_revenue": 0,
      "instructor_share": 0,
      "sales_count": 0,
      "total_trainings_sales": 0,
      "total_exams_sales": 0
    }
  },
  "weekly_earnings": {
    "1": {
      "total_revenue": 0,
      "instructor_share": 0,
      "sales_count": 0,
      "total_trainings_sales": 0,
      "total_exams_sales": 0
    }
  },
  "total_sales_trainings": 294.15,
  "total_sales_exams": 78
}
```

Note:

* in this endpoint, `total_sales_trainings` and `total_sales_exams` are earnings totals, not counts

***

## **13. Instructor Earnings Route**

> **v3 — Implemented.** This route now resolves to `InstructorDashboardController::totalInstructorEarnings()`. It returns a focused earnings summary (the instructor's share of every paid sale, split by trainings vs exams) computed with the same paid-order / share logic as the **Overview** endpoint (§9).

Returns the instructor's total earnings (their revenue share) across all paid sales.

| **Field** | **Value** |
| :- | :- |
| Method | `GET` |
| URL | `/api/instructor/{id}/earnings` |
| Auth | Bearer Token |

### **Example Request**

```text theme={null}
GET /api/instructor/7/earnings
Authorization: Bearer {{token}}
Accept: application/json
```

### **Success Response**

```text theme={null}
{
  "instructor_id": 7,
  "total_instructor_earnings": 372.15,
  "trainings_earnings": 294.15,
  "exams_earnings": 78,
  "total_revenue": 1240.5,
  "trainings_revenue": 980.5,
  "exams_revenue": 260,
  "total_sales_count": 14,
  "num_orders_trainings": 10,
  "num_orders_exams": 4,
  "avg_instructor_percentage": 30
}
```

Notes:

* `total_instructor_earnings` = `trainings_earnings` + `exams_earnings` (the instructor's share).
* `total_revenue` is the gross sale price before the share split; `*_earnings` are the instructor's cut.
* An instructor with no trainings and no exams returns the same shape with all numeric fields `0`.

### **Error Responses**

```text theme={null}
{
  "message": "You do not have permission to access this dashboard."
}
```

```text theme={null}
{
  "message": "Failed to fetch instructor earnings.",
  "error": "..."
}
```

***

## **14. Sales Details Summary**

Returns paginated training and exam summary rows for the instructor.

| **Field** | **Value** |
| :- | :- |
| Method | `GET` |
| URL | `/api/instructor/{instructor_id}/sales-details` |
| Auth | Bearer Token |

### **Query Parameters**

| **Name** | **Required** | **Notes** |
| :- | :- | :- |
| `page` | No | default `1` |
| `per_page` | No | default `20` |
| `training_id` | No | filter one training |
| `exam_id` | No | filter one exam |

### **Example Request**

```text theme={null}
GET /api/instructor/7/sales-details?page=1&per_page=10
Authorization: Bearer {{token}}
Accept: application/json
```

### **Success Response**

```text theme={null}
{
  "trainings": {
    "current_page": 1,
    "data": [
      {
        "id": 101,
        "label": "AI for Product Managers",
        "image_id": null,
        "revenue_percentage": 30,
        "type": "training",
        "total_sales_number": 8,
        "total_instructor_income": 234.15,
        "image": null
      }
    ],
    "per_page": 10,
    "total": 1
  },
  "exams": {
    "current_page": 1,
    "data": [
      {
        "id": 88,
        "label": "AI Fundamentals Final Exam",
        "revenue_percentage": 30,
        "type": "exam",
        "total_sales_number": 4,
        "total_instructor_income": 78
      }
    ],
    "per_page": 10,
    "total": 1
  },
  "trainings_sales_count": 8,
  "exams_sales_count": 4,
  "total_instructor_income_on_trainings": 234.15,
  "total_instructor_income_on_exams": 78
}
```

***

## **15. Sales Details Orders**

Returns the paginated order rows for one selected training or one selected exam.

| **Field** | **Value** |
| :- | :- |
| Method | `GET` |
| URL | `/api/instructor/{instructor_id}/sales-details-orders` |
| Auth | Bearer Token |

### **Query Parameters for Training**

| **Name** | **Required** | **Notes** |
| :- | :- | :- |
| `type` | Yes | `training` or `exam` |
| `training_id` | Yes when `type=training` | must belong to instructor |
| `page` | No | default `1` |
| `per_page` | No | default `20` |

### **Example Request for Training**

```text theme={null}
GET /api/instructor/7/sales-details-orders?type=training&training_id=101&page=1&per_page=10
Authorization: Bearer {{token}}
Accept: application/json
```

### **Success Response for Training**

```text theme={null}
{
  "id": 101,
  "label": "AI for Product Managers",
  "type": "training",
  "revenue_percentage": 30,
  "sales": {
    "current_page": 1,
    "data": [
      {
        "order_id": 401,
        "order_reference": "REF-ABCD123456",
        "order_system_log": null,
        "sale_date": "2026-02-12 10:30:00",
        "price": 120.5,
        "discounted_price": 120.5,
        "coupon_id": null,
        "voucher_id": null,
        "instructor_share": 36.15,
        "revenue_percentage": 30
      }
    ],
    "per_page": 10,
    "total": 1
  }
}
```

### **Example Request for Exam**

```text theme={null}
GET /api/instructor/7/sales-details-orders?type=exam&exam_id=88&page=1&per_page=10
Authorization: Bearer {{token}}
Accept: application/json
```

### **Success Response for Exam**

```text theme={null}
{
  "id": 88,
  "label": "AI Fundamentals Final Exam",
  "type": "exam",
  "revenue_percentage": 30,
  "sales": {
    "current_page": 1,
    "data": [
      {
        "order_id": 405,
        "order_reference": "REF-XYZ123456",
        "order_system_log": null,
        "sale_date": "2026-02-13 14:45:00",
        "price": 65,
        "discounted_price": 65,
        "coupon_id": null,
        "voucher_id": null,
        "instructor_share": 19.5,
        "revenue_percentage": 30
      }
    ],
    "per_page": 10,
    "total": 1
  }
}
```

### **Validation Error Example**

```text theme={null}
{
  "message": "Validation failed.",
  "errors": {
    "type": [
      "The type field is required."
    ]
  }
}
```

***

## **16. Export Dashboard PDF**

Exports the instructor dashboard summary as a PDF file.

| **Field** | **Value** |
| :- | :- |
| Method | `GET` |
| URL | `/api/instructor/{id}/export_stats_pdf` |
| Auth | Bearer Token |

### **Example Request**

```text theme={null}
GET /api/instructor/7/export_stats_pdf?year=2026
Authorization: Bearer {{token}}
Accept: application/pdf
```

### **Success Behavior**

* returns a PDF download
* file name pattern:

```text theme={null}
instructor_stats_{instructor_id}_{timestamp}.pdf
```

### **Error Example**

```text theme={null}
{
  "message": "Failed to export stats PDF.",
  "error": "..."
}
```

***

## **17. Export Dashboard CSV**

Exports the instructor dashboard summary as a CSV file.

| **Field** | **Value** |
| :- | :- |
| Method | `GET` |
| URL | `/api/instructor/{id}/export_stats_csv` |
| Auth | Bearer Token |

### **Example Request**

```text theme={null}
GET /api/instructor/7/export_stats_csv
Authorization: Bearer {{token}}
Accept: text/csv
```

### **Success Behavior**

* returns a CSV download
* file name pattern:

```text theme={null}
instructor_stats_{instructor_id}_{timestamp}.csv
```

### **CSV Columns Note**

The `Trainings Sales Count` and `Exams Sales Count` columns previously always exported `0` (they read `overview` keys that the endpoint never returns). They are now populated from `overview`'s `num_orders_trainings` / `num_orders_exams` (the per-type paid-sale counts). This affects the CSV file only — no JSON API response changed.

### **Error Example**

```text theme={null}
{
  "message": "Failed to export stats CSV.",
  "error": "..."
}
```

***

## **18. Internal Reused Methods**

The controller was refactored to remove the large duplicated "loop paid orders → match the sold item → compute the instructor share" blocks. The shared logic now lives in private helpers suffixed `_helper` (the public endpoints and **all of their JSON responses are unchanged**):

* `paid_order_statuses_helper()` Brief role: returns the paid order-status IDs (`PAID_ONLINE_ID`, `PAID_OFFLINE_ID`) treated as a sale.
* `process_sales_items_helper($items, $instructor, $statuses, $relation, $includeImage)` Brief role: aggregates training **or** exam paid-order revenue for `overview()` (replaces the old `processTrainings()`/`processExams()` twins). `$relation` is `trainings` or `quizzes`; `$includeImage` adds the model `image` to the per-item stats row (trainings only).
* `get_top_selling_helper(array $stats, $limit = 3)` Brief role: extracts the top-N (default 3) entries by `total_revenue` for the overview payload (replaces `getTopSellingTrainings()`/`getTopSellingExams()`).
* `collect_sale_events_helper($items, $instructor, $statuses, $relation, $year)` Brief role: flattens trainings/exams into per-sale events (`month`, `week`, `pivot_price`, `discounted_price`, `pct`) consumed by `monthlySales()`, `weeklySales()` and `weeklyMonthlyCombinedSales()`. Each caller keeps its own accumulation rules (see notes in §19).
* `empty_period_buckets_helper($count)` Brief role: builds zeroed month/week buckets (`1..$count`) for both the empty-data branches and as the accumulation base.
* `scale_buckets_helper(array $buckets)` Brief role: divides bucket figures by `1000` and formats to 3 decimals (used by `monthlySales()`/`weeklyMonthlyCombinedSales()`; **not** by `weeklySales()`).
* `summarize_instructor_items_helper($items, $instructor, $statuses, $relation, $type, $includeImage)` Brief role: builds the per-training/exam summary rows + aggregate count/income for `instructorSalesDetails()`.
* `build_sale_row_helper($order, $revPct)` Brief role: builds a single per-order sale row for `instructorSalesDetailsOrders()`.
* `getThemeForCompany($companyId)` Brief role: loads company branding used by `exportStatsPdf()`.

The export methods also reuse other endpoint methods internally:

* `exportStatsPdf()` reuses `overview()`, `monthlySales()`, and `weeklySales()`
* `exportStatsCsv()` reuses `overview()`

***

## **19. Known Implementation Notes**

* `/api/instructors/{instructor_id}/quizzes` is **implemented as of v4** (`UsersController::quizzes_by_instructor()`); see §7.
* `/api/instructor/{id}/earnings` is **implemented as of v3** (`totalInstructorEarnings()`); see §13.
* `monthlySales()` and `weeklyMonthlyCombinedSales()` divide returned month/week figures by `1000`, while `weeklySales()` returns raw values.
* `weeklySales()` uses different underlying user relations than the other dashboard methods, so totals may not perfectly match `overview()` or `monthlySales()` in every dataset.
* The `total_trainings_sales` / `total_exams_sales` bucket lines are intentionally **not** the same quantity across the time-series endpoints: `monthlySales()` accumulates the (discounted) **price**, `weeklySales()` accumulates the **pivot price**, and `weeklyMonthlyCombinedSales()` accumulates the instructor **share**. This pre-existing behaviour is preserved.
* June 2026 refactor: the controller's duplicated per-order loops were extracted into `_helper` methods and a \~170-line commented-out legacy `instructorSalesDetails()` block was removed. Endpoint signatures, routes and JSON responses are unchanged.

***

## **20. Background Items API**

Background items represent education, work experience, and certifications attached to a user profile. They are typically used to enrich the instructor's public profile (resume-like cards), but the routes themselves accept any `userId`.

The model is `App\Models\UserBackgroundItem` and the controller is `App\Http\Controllers\UserBackgroundItemsController`.

> **v3 — Logo storage moved to Cloudflare R2.** As of v3, an uploaded `logo` is stored on **Cloudflare R2** (not local server disk), grouped under the `files/instructor_background_items/` prefix in the bucket (`files/instructor_bg_items/` since v4, below). The created `File` row is `type = 3` (R2) and its `url` is a public R2 URL. Each logo is saved under a unique object key (`{userId}_{time}_{uniqid}_{originalName}`), so every `File` row maps **1-to-1** to its own object — the cross-item deletion bug described in earlier versions is resolved (see §20.5). **No frontend change is required:** the response shape is unchanged and the logo is still read from `image.url`; only the URL host changes (now an R2 URL instead of `{APP_URL}/storage/...`).

> **v4 — Logo folder and key (01/11/2026).** New logos are written to `files/instructor_bg_items/{userId}_{time}_{originalName}` (the `uniqid` part is gone; the user id and the second keep the key unique), the folder of the bucket layout on [R2 Storage Paths](https://theteam-13fc7f49.mintlify.site/api-reference/r2-storage-paths.md) (`app/Services/R2StoragePathService.php`). Logos uploaded before keep their `files/instructor_background_items/…` keys and URLs. The response shape is unchanged.

> **v3.1 — Upload now verified; logo cleanup moved to the model observer.** Two reliability fixes:
>
> 1. **Verified upload (no more dead URLs).** The `r2` disk does not enable Flysystem's `throw` option, so a failed `putFileAs()` previously returned `false` *silently* while the code still saved a `File` row — leaving a stored `url` that returned **404** because the object never reached the bucket. `storeLogoFile()` now checks the upload result **and** confirms the object exists in R2 before creating the `File` row; on failure it throws, so the endpoint returns `500` instead of persisting a broken link.
> 2. **Disk-correct, guarded cleanup.** Logo deletion (on replace and on delete) is now owned by `UserBackgroundItemObserver`, which removes the object from the **correct disk** (R2 for `type = 3`, local `public/files/...` for legacy `type = 1`) and only when **no other item still references the same** `logo_id`. The previous observer deleted via the default (`local`) disk, so R2 objects were orphaned.

### **Model Fields**

| **Field** | **Type** | **Notes** |
| :- | :- | :- |
| `id` | integer | primary key |
| `user_id` | integer | owner of the item |
| `organization_institute_name` | string (max 255) | required |
| `title` | string (max 255) | required |
| `level` | string (max 255), nullable | e.g. degree level, role level |
| `description` | text, nullable | free text |
| `start_date` | date, nullable | |
| `end_date` | date, nullable | must be after or equal to `start_date` |
| `status` | enum: `education`, `work`, `certification` | nullable |
| `logo_id` | integer, nullable | foreign key to `files.id` (R2 file, `type = 3`) |
| `image` | object | appended file resource (the logo); `url` is a public R2 URL |

***

## **20.1 List Background Items for a User**

Returns all background items for one user, optionally filtered by `status`.

| **Field** | **Value** |
| :- | :- |
| Method | `GET` |
| URL | `/api/users/{userId}/background-items` |
| Auth | Bearer Token |

### **Query Parameters**

| **Name** | **Required** | **Notes** |
| :- | :- | :- |
| `status` | No | one of `education`, `work`, `certification` |

### **Example Request**

```text theme={null}
GET /api/users/7/background-items?status=education
Authorization: Bearer {{token}}
Accept: application/json
```

### **Success Response**

```text theme={null}
[
  {
    "id": 12,
    "user_id": 7,
    "organization_institute_name": "MIT",
    "title": "MSc Computer Science",
    "level": "Master",
    "description": "Specialization in AI and machine learning.",
    "start_date": "2018-09-01 00:00:00",
    "end_date": "2020-06-30 00:00:00",
    "status": "education",
    "logo_id": 88,
    "created_at": "2026-01-12 09:00:00",
    "updated_at": "2026-01-12 09:00:00",
    "image": {
      "id": 88,
      "company_id": 1,
      "user_id": 7,
      "type": 3,
      "file_name": "files/instructor_bg_items/7_1717420800_mit_logo.png",
      "url": "https://pub-f784063c7e034df99a5468d0a840b67b.r2.dev/files/instructor_bg_items/7_1717420800_mit_logo.png"
    }
  }
]
```

***

## **20.2 Create Background Item**

Creates a new background item for the given user. Accepts an optional `logo` file upload. When a `logo` is sent, it is uploaded to **Cloudflare R2** under `files/instructor_bg_items/`, a `File` row (`type = 3`) is created, and its id is stored in `logo_id`.

> The R2 upload is **verified** before the `File` row is saved (see §20 v3.1). If the upload to R2 fails, the endpoint returns `500` with the error message and **no** background item or `File` row is created — you will never get back a `logo` URL that 404s.

| **Field** | **Value** |
| :- | :- |
| Method | `POST` |
| URL | `/api/users/{userId}/background-items` |
| Auth | Bearer Token |

### **Request Body (multipart/form-data)**

| **Field** | **Required** | **Type** |
| :- | :- | :- |
| `organization_institute_name` | Yes | string (max 255) |
| `title` | Yes | string (max 255) |
| `level` | No | string (max 255) |
| `description` | No | text |
| `start_date` | No | date (`YYYY-MM-DD`) |
| `end_date` | No | date, must be `>= start_date` |
| `status` | No | one of `education`, `work`, `certification` |
| `logo` | No | file: `jpeg`, `png`, `jpg` — max 5 MB |

### **Example Request (JSON-only, no file)**

```text theme={null}
POST /api/users/7/background-items
Authorization: Bearer {{token}}
Content-Type: application/json
Accept: application/json

{
  "organization_institute_name": "MIT",
  "title": "MSc Computer Science",
  "level": "Master",
  "description": "Specialization in AI and machine learning.",
  "start_date": "2018-09-01",
  "end_date": "2020-06-30",
  "status": "education"
}
```

### **Example Request (with logo upload)**

```text theme={null}
POST /api/users/7/background-items
Authorization: Bearer {{token}}
Content-Type: multipart/form-data

organization_institute_name: MIT
title: MSc Computer Science
level: Master
description: Specialization in AI and machine learning.
start_date: 2018-09-01
end_date: 2020-06-30
status: education
logo: <binary file>
```

### **Success Response**

```text theme={null}
{
  "id": 12,
  "user_id": 7,
  "organization_institute_name": "MIT",
  "title": "MSc Computer Science",
  "level": "Master",
  "description": "Specialization in AI and machine learning.",
  "start_date": "2018-09-01 00:00:00",
  "end_date": "2020-06-30 00:00:00",
  "status": "education",
  "logo_id": 88,
  "created_at": "2026-05-08 10:30:00",
  "updated_at": "2026-05-08 10:30:00",
  "image": {
    "id": 88,
    "type": 3,
    "file_name": "files/instructor_bg_items/7_1717420800_mit_logo.png",
    "url": "https://pub-f784063c7e034df99a5468d0a840b67b.r2.dev/files/instructor_bg_items/7_1717420800_mit_logo.png"
  }
}
```

### **Validation Error Example**

```text theme={null}
{
  "message": "The organization institute name field is required. (and 1 more error)",
  "errors": {
    "organization_institute_name": [
      "The organization institute name field is required."
    ],
    "title": [
      "The title field is required."
    ]
  }
}
```

***

## **20.3 Show Background Item**

Returns one background item by id.

| **Field** | **Value** |
| :- | :- |
| Method | `GET` |
| URL | `/api/background-items/{id}` |
| Auth | Bearer Token |

### **Example Request**

```text theme={null}
GET /api/background-items/12
Authorization: Bearer {{token}}
Accept: application/json
```

### **Success Response**

```text theme={null}
{
  "id": 12,
  "user_id": 7,
  "organization_institute_name": "MIT",
  "title": "MSc Computer Science",
  "level": "Master",
  "description": "Specialization in AI and machine learning.",
  "start_date": "2018-09-01 00:00:00",
  "end_date": "2020-06-30 00:00:00",
  "status": "education",
  "logo_id": 88,
  "image": {
    "id": 88,
    "type": 3,
    "file_name": "files/instructor_bg_items/7_1717420800_mit_logo.png",
    "url": "https://pub-f784063c7e034df99a5468d0a840b67b.r2.dev/files/instructor_bg_items/7_1717420800_mit_logo.png"
  }
}
```

### **Not Found Response**

```text theme={null}
{
  "message": "Background item not found."
}
```

***

## **20.4 Update Background Item**

Updates one background item. All fields are optional. If `logo` is provided, it is uploaded to **Cloudflare R2** (under `files/instructor_bg_items/`), a new `File` row (`type = 3`) is created, and `logo_id` is repointed to it. The new upload is **verified** the same way as create (§20.2) — a failed R2 write returns `500` and does not repoint `logo_id`.

> **Note (changed in v3.1):** replacing the logo now **deletes the previously attached logo** — `UserBackgroundItemObserver@updating` removes the old `File` row and its R2 object — **unless** another background item still references that same `logo_id` (shared/legacy logos are preserved). Earlier versions left the old `File`/object orphaned.

| **Field** | **Value** |
| :- | :- |
| Method | `POST` |
| URL | `/api/background-items/{id}` |
| Auth | Bearer Token |

### **Request Body (multipart/form-data or JSON)**

| **Field** | **Required** | **Type** |
| :- | :- | :- |
| `organization_institute_name` | No | string (max 255) |
| `title` | No | string (max 255) |
| `level` | No | string (max 255) |
| `description` | No | text |
| `start_date` | No | date |
| `end_date` | No | date, must be `>= start_date` |
| `status` | No | one of `education`, `work`, `certification` |
| `logo` | No | file: `jpeg`, `png`, `jpg` — max 5 MB |

### **Example Request**

```text theme={null}
POST /api/background-items/12
Authorization: Bearer {{token}}
Content-Type: application/json
Accept: application/json

{
  "title": "MSc Artificial Intelligence",
  "level": "Master",
  "description": "Updated focus on deep learning."
}
```

### **Success Response**

```text theme={null}
{
  "id": 12,
  "user_id": 7,
  "organization_institute_name": "MIT",
  "title": "MSc Artificial Intelligence",
  "level": "Master",
  "description": "Updated focus on deep learning.",
  "start_date": "2018-09-01 00:00:00",
  "end_date": "2020-06-30 00:00:00",
  "status": "education",
  "logo_id": 88,
  "image": {
    "id": 88,
    "type": 3,
    "file_name": "files/instructor_bg_items/7_1717420800_mit_logo.png",
    "url": "https://pub-f784063c7e034df99a5468d0a840b67b.r2.dev/files/instructor_bg_items/7_1717420800_mit_logo.png"
  }
}
```

### **Not Found Response**

```text theme={null}
{
  "message": "Background item not found."
}
```

***

## **20.5 Delete Background Item**

Soft-deletes the background item (inside a DB transaction). If the item has a `logo_id`, the linked `File` row is also removed **and** its object is deleted from the **correct disk** (R2 for `type = 3`, local for legacy `type = 1`) — but only when **no other background item still references the same** `logo_id` (guarding shared/legacy logos).

> As of v3.1 this cleanup lives in `UserBackgroundItemObserver@deleting` (a single source of truth fired by `$item->delete()`), not inline in `destroy()`. The controller now only soft-deletes the item; the observer handles disk selection and the shared-logo guard.

| **Field** | **Value** |
| :- | :- |
| Method | `DELETE` |
| URL | `/api/background-items/{id}` |
| Auth | Bearer Token |

### **Example Request**

```text theme={null}
DELETE /api/background-items/12
Authorization: Bearer {{token}}
Accept: application/json
```

### **Success Response**

```text theme={null}
{
  "message": "Background item deleted successfully."
}
```

### **Error Response**

```text theme={null}
{
  "message": "<error description>"
}
```

### **Resolved Issue (v3)**

Earlier versions had a bug: deleting a background item with a logo could destroy the image belonging to **another** item, because logos were saved on local disk under their raw client filename — two logos sharing a filename overwrote the same physical file, while the `File` model's delete hook removed files **by filename**.

This is fixed in v3:

* Logos are uploaded to R2 under a **unique** object key (`{userId}_{time}_{uniqid}_{originalName}`), so every `File` row maps 1-to-1 to its own object — uploads can no longer collide.
* The logo `File`/R2 object is deleted only when **no other item references the same** `logo_id`, protecting any pre-existing shared/legacy data.

### **Additional fixes (v3.1)**

* **Dead-URL 404s eliminated.** A failed R2 upload used to fail silently (the `r2` disk has no `throw`) yet still saved a `File` row, producing a stored `url` that 404'd. Uploads are now verified (`storeLogoFile()` checks the put result and object existence) before any `File` row is created.
* **Orphaned R2 objects on delete/replace eliminated.** Logo cleanup was deleting from the default (`local`) disk, so R2 objects were never removed. Cleanup is now centralized in `UserBackgroundItemObserver` and routes to the correct disk by `File::type`, with the shared-logo guard applied on both **replace** (`updating`) and **delete** (`deleting`).

> **Legacy data:** logos uploaded before v3 remain on local disk as `type = 1` files with their original URLs; they are not auto-migrated to R2. New and updated logos go to R2. If you need the old logos relocated, run a one-off migration.
>
> **Already-broken** `File `**rows:** any `File` row created before v3.1 whose R2 object never uploaded will still hold a 404 URL — those are not auto-cleaned. Re-upload the logo (or remove the orphan `File` row) to fix it.


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