# Books API — Admin Dashboard

Admin/dashboard endpoints for managing books & research papers, and their physical orders.

- **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:** the route name with its `admin.` prefix stripped is the permission checked, e.g. `admin.books.store` → `books.store` (direct grant or `books.*`)
- **Source:** `app/Http/Controllers/Api/V1/Admin/BookController.php`, `BookOrderController.php`, `app/Services/Admin/BookService.php`, `BookOrderService.php`, `app/Models/Book.php`, `app/Models/Order.php`

## Conventions

**Envelope** — every response is `{ "status": "success" | "fail" | "error", "message": string, "data": ... }`.

| status | when | data |
|---|---|---|
| `success` | 200 / 201 | the resource, a collection, or `[]` |
| `fail` | 401 unauthenticated · 403 permission denied · 404 not found · 422 validation · 429 throttled | `null`. On 422 validation, `message` is the **first** failing rule only — not a field map. |
| `error` | a typed domain exception (see **Error codes** below) | `{ "error_code": "..." }` |
| `error` (500) | a raw SQL error (e.g. filtering by a field that isn't a real column — see **Filtering pitfalls**) | `message` is `"Database error"` in production, the raw SQL error in debug mode |

**Headers** on every request: `Accept: application/json`, `Content-Type: application/json`, `Accept-Language: en|ar`. No endpoint in this document needs any other header.

**Media fields** (`cover_image`, `digital_file`) are never file uploads on these endpoints — upload the file first via `POST {{media_base_url}}/media` (`multipart/form-data`, field `file`, ≤10MB, `pdf` allowed for `digital_file`), then send the returned `hash` as the field value here.

### How list endpoints work (index pipeline)

Both list endpoints in this document (`GET /admin/books`, `GET /admin/book-orders`) run through the shared **Index pipeline**: `ApplyScopes → ApplyFilters → ApplySearch → ApplySorting → paginate`. This pipeline only ever touches fields declared on the model's `$searchableFields` array — anything else in `filters[...]`/`sort[...]` is silently ignored.

| Param | Always available | Behavior |
|---|---|---|
| `page` | yes | 1-based page number |
| `per_page` | yes | default 15, clamped to **max 100** |
| `paginate=false` (or `0`) | yes | skip pagination, return up to **500** rows as a plain array |
| `search=<term>` | yes, per resource | case-insensitive `LIKE %term%` **OR-ed across every field in `$searchableFields`**; a translated field (see per-resource table) is matched via `whereHas('translations', ...)` in the *current request locale* — this is the only reliable way to text-search a translated field |
| `sort[field]=asc\|desc` | only for fields in `$searchableFields` | a translated field sorts correctly via a correlated subquery on the translations table; any other field name is silently dropped (no error, just ignored) |
| `filters[field]=value` | only for fields in `$searchableFields` | plain `WHERE field = value`, unless the model declares a `scope{Field}()` method (see per-resource table) — **a translated field used as a `filters[...]` key is *not* special-cased and will throw a SQL "unknown column" error (500)**, because `ApplyFilters` (unlike `ApplySearch`/`ApplySorting`) does not check `$translatedAttributes` |

⚠️ **Filtering pitfalls to avoid:**
- Never send `filters[title]`, `filters[author]`, `filters[description]` (Book) — these are translated attributes with no real column; use `search=` instead. Same applies to `filters[name]`/`filters[description]` on the Courses API.
- `filters[category_id]` on Book accepts the category's **uuid** (there's a `scopeCategory_id()` that maps it to the internal id for you). There is no equivalent for admin book *orders* or *courses* — see each resource's table.

---

## Error codes used in this domain

| HTTP | error_code | Thrown when |
|---|---|---|
| 404 | *(fail, no code — `Data not found`)* | Book / order uuid doesn't resolve (route-model binding / `firstOrFail`) |
| 422 | `BOOK_HAS_ORDERS` | Deleting a book that has order history — hide it (`is_visible=false`) instead |
| 422 | `STOCK_BELOW_ZERO` | A `stock_adjustment` delta would push `stock_quantity` negative |
| 422 | `NOT_DIGITAL` | `GET books/{book}/file` on a physical book |
| 422 | `INVALID_STATE_TRANSITION` | `PUT book-orders/{order}/status` on a digital order, or one already `delivered` |
| 422 | *(fail, standard validation)* | FormRequest rule failures (`BookRequest`, `BookOrderStatusRequest`) |
| 403 | *(fail)* | `GET books/{book}/file` without the private-media ability (super_admin / supervisor only) |
| 500 | *(error, "Database error")* | A `filters[...]` key names a translated attribute — see **Filtering pitfalls** |

---

## Cycle — managing books & their orders

Admin book management has two independent loops: authoring the catalog, and fulfilling physical orders placed by users on the client side.

| # | When (admin UI) | Method & Endpoint | Notes |
|---|---|---|---|
| 1 | Books list screen loads | `GET /admin/books` | Filter/search/sort per the tables below |
| 2 | Admin opens "New Book" | `POST /admin/books` | `content_type` fixed once created; `prices[]` needs exactly one `is_default: true`; a visible digital book/research needs `digital_file` |
| 3 | Admin edits a book | `PUT /admin/books/{book}` | `content_type` is immutable (server forces the existing value); `stock_adjustment` applies a signed delta, not a replacement |
| 4 | Admin previews the PDF | `GET /admin/books/{book}/file` | Digital/research only; `?download=1` forces attachment instead of inline |
| 5 | Admin retires a title | `DELETE /admin/books/{book}` | Refused (`BOOK_HAS_ORDERS`) once any order references it — set `is_visible: false` instead |
| 6 | Bulk cleanup | `DELETE /admin/books/bulk-delete` | Same per-book guard as single delete; batched by `ids[]` |
| 7 | *(separate loop)* User places a physical order on the client app | — | Creates an `Order` with `type=physical`, `status=processing` — see [books-user.md](./books-user.md#cycle) |
| 8 | Order fulfillment screen loads | `GET /admin/book-orders` | Filter/search/sort per the table below — **not pre-scoped to physical orders**, despite the "طلبات الكتب الورقية" name (see note under that endpoint) |
| 9 | Admin opens one order | `GET /admin/book-orders/{order}` | Shows items, shipping address, payment |
| 10 | Admin ships it | `PUT /admin/book-orders/{order}/status` | Only `processing → delivered` is allowed; digital orders and already-delivered orders 422 (`INVALID_STATE_TRANSITION`) |
| 11 | *(cross-reference)* Who has enrolled/bought | `GET /admin/enrolled-users` | Shared enrollment view across courses/programs — not book-specific |

---

## Books

`Book::$searchableFields = ['category_id', 'content_type', 'is_available', 'is_visible', 'title', 'author', 'description']` · translated: `title`, `author`, `description`.

### `GET /admin/books` — List books

**Query parameters**

| Key | Type | Example | Notes |
|---|---|---|---|
| `page` | integer | `1` | — |
| `per_page` | integer | `15` | max 100 |
| `search` | string | `financial modelling` | matches `title`, `author`, `description` (translated, current locale) — `category_id`/`content_type`/`is_available`/`is_visible` also participate in the OR'd LIKE, but matching a raw boolean/enum column by substring is rarely useful in practice |
| `sort[created_at]` | `asc`\|`desc` | `desc` | any `$searchableFields` key works, including `sort[title]`/`sort[author]`/`sort[description]` (translation-aware) |
| `paginate` | boolean | `false` | disables pagination, caps at 500 rows |
| `filters[content_type]` | enum | `digital_book` | `physical_book`\|`digital_book`\|`research`, plain equality |
| `filters[category_id]` | **uuid** | `{{category_id}}` | resolved through `scopeCategory_id()` to the internal id — this is the one filter here that *does* accept the uuid the API returns elsewhere |
| `filters[is_available]` | `1`\|`0` | `1` | plain equality |
| `filters[is_visible]` | `1`\|`0` | `1` | plain equality |
| ~~`country_id`~~ | — | — | **not read by this endpoint.** The list always shows each book's own default price/currency (`currencyCountry` relation); per-country overrides are only visible via `GET /admin/books/{book}` (`prices[]`). Do not send it expecting localized prices here. |

**200 OK**
```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": [
    {
      "id": "5b6b6e2a-2222-4c3e-9a1e-000000000001",
      "title": "Building Your First Model",
      "author": "Dr. Farooq Al-Bateel",
      "content_type": "digital_book",
      "category": { "id": "6c1a...", "name": "Finance" },
      "price": "49.99",
      "currency": { "id": "6c1a...", "code": "USD", "symbol": "$" },
      "stock_quantity": null,
      "is_visible": true,
      "is_available": true,
      "cover_image": "https://cdn.example.com/media/cover.jpg",
      "created_at": "2026-09-14 07:54:11"
    }
  ]
}
```
When paginated (default), the array is instead wrapped: `data.books` holds the rows plus `current_page`, `per_page`, `total`, `last_page` (Laravel's standard paginator JSON, resource-wrapped under the model's table name).

### `POST /admin/books` — Create a book or research paper

**Body**
```json
{
  "content_type": "digital_book",
  "category_id": "{{category_id}}",
  "prices": [{ "country_id": "{{country_id}}", "price": 49.99, "is_default": true }],
  "is_visible": true,
  "is_available": true,
  "cover_image": "{{media_hash}}",
  "digital_file": "{{media_hash}}",
  "en": { "title": "Building Your First Model", "author": "Dr. Farooq Al-Bateel", "description": "A practical course covering financial modelling from first principles." },
  "ar": { "title": "بناء نموذجك المالي الأول", "author": "د. فاروق البتيل", "description": "دورة عملية تغطي النمذجة المالية من الأساسيات حتى التطبيق." }
}
```

| Field | Type | Required | Rules |
|---|---|---|---|
| `content_type` | enum | yes (create only) | `physical_book`\|`digital_book`\|`research`; **immutable** after create |
| `category_id` | uuid | no | `exists:categories,uuid` |
| `price`, `currency_country_id` | — | — | **prohibited** — always derived from `prices[]` |
| `prices` | array | yes | min 1; exactly **one** row with `is_default: true` (checked in `after()`, error attaches to `prices`) |
| `prices.*.country_id` | uuid | yes | `distinct`, country must have a `currency_code` |
| `prices.*.price` | number | yes | `min:0`, 2 decimals |
| `prices.*.is_default` | boolean | yes | — |
| `stock_quantity` | integer | physical only | required for `physical_book`, **prohibited** otherwise |
| `stock_adjustment` | integer | update only | signed delta applied atomically (see `PUT` below); `not_in:0` |
| `is_visible`, `is_available` | boolean | no | default per column |
| `cover_image` | media hash | no | image |
| `digital_file` | media hash | digital/research | **prohibited** for `physical_book`; if the book is (or becomes) visible and has no file, 422 on `digital_file` |
| `{en,ar}.title` | string | yes | max 200 |
| `{en,ar}.author` | string | no | max 150 |
| `{en,ar}.description` | string | no | — |

**201 Created** — same shape as detail below, plus `description`, `prices[]`, `has_file`, `digital_file` (metadata only, never a URL/path).

**422 fail examples**
```json
{ "status": "fail", "message": "Exactly one price must be marked as the default.", "data": null }
```
```json
{ "status": "fail", "message": "A visible digital book or research paper needs its PDF file.", "data": null }
```
```json
{ "status": "fail", "message": "The selected content type is invalid.", "data": null }
```

### `GET /admin/books/{book}` — Book detail

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

**200 OK**
```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "id": "5b6b6e2a-2222-4c3e-9a1e-000000000001",
    "title": "Building Your First Model",
    "author": "Dr. Farooq Al-Bateel",
    "content_type": "digital_book",
    "category": { "id": "6c1a...", "name": "Finance" },
    "price": "49.99",
    "currency": { "id": "6c1a...", "code": "USD", "symbol": "$" },
    "stock_quantity": null,
    "is_visible": true,
    "is_available": true,
    "cover_image": "https://cdn.example.com/media/cover.jpg",
    "created_at": "2026-09-14 07:54:11",
    "description": "A practical course covering financial modelling from first principles.",
    "prices": [
      { "country_id": "{{country_id}}", "country_name": "Egypt", "currency_code": "USD", "currency_symbol": "$", "price": "49.99", "is_default": true },
      { "country_id": "{{other_country_id}}", "country_name": "Saudi Arabia", "currency_code": "SAR", "currency_symbol": "ر.س", "price": "180.00", "is_default": false }
    ],
    "has_file": true,
    "digital_file": { "name": "model-101.pdf", "size": 2456789, "mime_type": "application/pdf" }
  }
}
```
Because this model is translatable, the show pipeline also appends an all-locale block per configured locale (e.g. `en`, `ar`) with the raw translation rows, for populating an edit form in every language — in addition to the current-locale flat fields above.

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

### `PUT /admin/books/{book}` — Update a book

Same field table as create, all `sometimes`. `content_type` is silently forced back to the book's existing value (sending a different one is `in:[<current>]`, i.e. a 422). `stock_adjustment` (physical only) applies a signed delta in one conditional `UPDATE ... WHERE stock_quantity >= -delta`; a concurrent decrement can never take stock negative.

**Body — increase stock by 20:**
```json
{ "stock_adjustment": 20 }
```
**Body — decrease stock by 5:**
```json
{ "stock_adjustment": -5 }
```

**422 fail — stock adjustment would go negative**
```json
{ "status": "error", "message": "Stock cannot go below zero.", "data": { "error_code": "STOCK_BELOW_ZERO" } }
```
**422 fail — wrong content_type sent**
```json
{ "status": "fail", "message": "The selected content type is invalid.", "data": null }
```

### `GET /admin/books/{book}/file` — Preview / download the PDF

**Path variables:** `book`. **Query:** `download=1` for `Content-Disposition: attachment` (default is `inline`).

Streams the raw PDF (`Content-Type: application/pdf`, `X-Content-Type-Options: nosniff`, `Cache-Control: private, no-store`) — **not** a JSON envelope on success.

**Errors**
```json
{ "status": "error", "message": "Only digital books and research papers have a PDF file.", "data": { "error_code": "NOT_DIGITAL" } }
```
- `403 fail` if the caller lacks the private-media ability (only `super_admin` / `supervisor`): `{ "status": "fail", "message": "You are not authorized to make this action", "data": null }`
- `404 fail "Data not found"` if the book, or its stored file on disk, is missing.

### `DELETE /admin/books/{book}` — Delete a book

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

**422 — book has order history**
```json
{ "status": "error", "message": "This book has orders and cannot be deleted — hide it instead.", "data": { "error_code": "BOOK_HAS_ORDERS" } }
```

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

**Body:** `{ "ids": ["{{book_id}}", "{{book_id_2}}"] }` — `ids` is an array of book **uuid**s. Each book runs the same `BOOK_HAS_ORDERS` guard inside one transaction — if any one of them has orders, the **whole batch rolls back** and no book is deleted.

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

---

## Book orders

`Order::$searchableFields = ['order_number', 'user_id', 'type', 'status', 'created_at']` — no translated attributes, no custom scopes. Read-only index/show plus a single status transition — admin never creates, edits items on, or deletes an order (no `store`/`destroy` routes).

> **Note:** despite the "إدارة طلبات الكتب الورقية" (physical book orders) framing, `BookOrderService` does **not** override the query scope by `type` — `GET /admin/book-orders` returns every order (physical *and* digital) unless you add `filters[type]=physical` yourself. Digital orders show up already `status: delivered` (see [books-user.md](./books-user.md#checkout--digital-purchase)).

### `GET /admin/book-orders` — List orders

**Query parameters**

| Key | Type | Example | Notes |
|---|---|---|---|
| `page`, `per_page`, `paginate=false` | — | — | same as Books above |
| `search` | string | `ORD-2026` | LIKE across `order_number`, `user_id`, `type`, `status`, `created_at` — in practice only `order_number` is useful to text-search |
| `sort[created_at]` | `asc`\|`desc` | `desc` | any of `order_number`, `user_id`, `type`, `status`, `created_at` |
| `filters[status]` | enum | `processing` | **`processing`\|`delivered`** — these are the only two `Order` status values in this codebase (there is no `placed`/`cancelled`) |
| `filters[type]` | enum | `physical` | `physical`\|`digital` — not applied by default, see note above |
| `filters[order_number]` | string | `ORD-20260914-0001` | exact match |
| `filters[user_id]` | — | — | compares against the **internal integer** `user_id` column — the API never exposes that id (only the user's uuid), so this filter is not practically usable from a client |

**200 OK**
```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": [
    {
      "id": "8a9b...",
      "order_number": "ORD-20260914-0001",
      "user": { "id": "u-uuid", "name": "Sara Ahmed" },
      "first_item": { "title": "Building Your First Model" },
      "items_count": 2,
      "type": "physical",
      "status": "processing",
      "subtotal": "99.98",
      "shipping_price": "10.00",
      "total_price": "109.98",
      "currency": { "code": "USD", "symbol": "$" },
      "created_at": "2026-09-14T07:54:11+00:00"
    }
  ]
}
```

### `GET /admin/book-orders/{order}` — Order detail

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

**200 OK**
```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "id": "8a9b...",
    "order_number": "ORD-20260914-0001",
    "user": { "id": "u-uuid", "name": "Sara Ahmed", "phone": "1001234567", "email": "sara@example.com" },
    "first_item": { "title": "Building Your First Model" },
    "items_count": 2,
    "type": "physical",
    "status": "processing",
    "subtotal": "99.98",
    "shipping_price": "10.00",
    "total_price": "109.98",
    "currency": { "code": "USD", "symbol": "$" },
    "created_at": "2026-09-14T07:54:11+00:00",
    "items": [
      { "book": { "id": "book-uuid", "title": "Building Your First Model" }, "quantity": 2, "unit_price": "49.99", "line_total": "99.98" }
    ],
    "address": { "name": "Sara Ahmed", "phone": "1001234567", "line1": "12 Tahrir St.", "city": "Cairo", "country": "Egypt" },
    "payment": { "method": "wallet", "status": "success", "amount": "99.98", "paid_at": "2026-09-14T07:54:20+00:00" },
    "delivered_at": null
  }
}
```
`address` is `null` for a digital order (`type: digital`); it is rendered from an immutable snapshot taken at checkout time, in the current request's locale — later edits to the user's saved address never change it.

### `PUT /admin/book-orders/{order}/status` — Mark delivered

**Path variables:** `order`. **Body:** `{ "status": "delivered" }` — the only accepted value (`Rule::in(['delivered'])`); anything else is a 422 validation error, e.g. `{"status":"fail","message":"The selected status is invalid.","data":null}`.

Runs under a row lock; only `type=physical` orders not already `delivered` qualify.

**422 — wrong state**
```json
{ "status": "error", "message": "This order cannot be marked delivered.", "data": { "error_code": "INVALID_STATE_TRANSITION" } }
```

**200 OK** — the updated order (`BookOrderResource` shape, same as the list row above), `status: "delivered"`, `delivered_at` set to the current timestamp.
