# Consultations API — Admin Dashboard

Admin management of the consultation catalog (types, trainer offerings, bookable slots) and read-only oversight of bookings, plus the one deliberate write action on a booking: setting its meeting link.

- **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 (e.g. `admin.trainer-consultants.store` → `trainer-consultants.store`)
- **Source:** `app/Http/Controllers/Api/V1/Admin/{ConsultationType,TrainerConsultant,TrainerConsultantSlot,ConsultationBooking}Controller.php`, `app/Services/Admin/{ConsultationTypeService,TrainerConsultantService,ConsultationBookingService}.php`, `app/Services/Shared/SlotPublisher.php`

## Conventions

Same envelope, headers, and index-pipeline mechanics as [books-dashboard.md](./books-dashboard.md#conventions) — `{status, message, data}`; `page`/`per_page`/`search`/`sort[...]`/`filters[...]` only work on a model's `$searchableFields`, translated-field filters 500, translated-field search/sort work correctly. See that doc for the full mechanics if you haven't read it yet.

**Three levels of "not allowed" in this domain — don't conflate them:**

| Shape | Example | Comes from |
|---|---|---|
| `{"status":"fail","message":"Data not found","data":null}` (404) | Booking/type/offering/slot uuid doesn't resolve | plain `ModelNotFoundException` |
| `{"status":"fail","message":"<validation message>","data":null}` (422) | A FormRequest rule failed | `ApiMasterRequest::failedValidation()` |
| `{"status":"error","message":"<message>","data":{"error_code":"BUSINESS_RULE_VIOLATED"}}` (422) | A named business rule was violated (duplicate offering, booked slot, referenced type) | typed `BusinessRuleViolation` |
| `{"status":"error","message":"<message>","data":{"error_code":"PERMISSION_DENIED"}}` (403) | Meeting-link ownership conflict | typed `AuthorizationDeniedException` |

## Error codes used in this domain

| HTTP | error_code | Thrown when |
|---|---|---|
| 404 | *(fail, `Data not found`)* | Consultation type / trainer-consultant offering / slot / booking uuid doesn't resolve |
| 422 | `BUSINESS_RULE_VIOLATED` | Deleting a consultation type still offered by a trainer; deleting a trainer-consultant offering that has bookings; creating a duplicate (trainer, type) offering; deleting an already-booked slot |
| 422 | *(fail, standard validation)* | `ConsultationTypeRequest`, `TrainerConsultantRequest`, `TrainerConsultantSlotRequest`, `SetMeetingLinkRequest` |
| 403 | `PERMISSION_DENIED` | Setting a booking's meeting link once the **trainer** has already set it — whoever sets it first owns it |
| 405→404 | — | `POST`/`PUT`/`PATCH`/`DELETE` on `consultation-bookings` or `consultation-bookings/{id}` are explicitly routed to `abort(404)` — a plain 404, not Laravel's default 405, since this resource is genuinely read-only apart from `meeting-link` |
| 500 | *(error, "Database error")* | `filters[name]` / `filters[description]` on `GET /admin/consultation-types` — translated attributes, see [books-dashboard.md pitfalls](./books-dashboard.md#conventions) |

---

## Cycle — building the consultation catalog, then overseeing bookings

| # | When | Method & Endpoint | Notes |
|---|---|---|---|
| 1 | Define what can be booked | `POST /admin/consultation-types` | e.g. "Financial Consultation", "Tax Advisory" |
| 2 | Assign a trainer to offer it, at a price | `POST /admin/trainer-consultants` | `{trainer_id, consultation_type_id, price, duration_minutes}` — one row per (trainer, type) pair |
| 3 | Publish bookable time slots for that offering | `POST /admin/trainer-consultants/{trainer_consultant}/slots` | Bulk `{dates[], times[]}` cross-product; admin can also do this — trainers can self-publish too (see [consultations-trainer.md](./consultations-trainer.md)) |
| 4 | *(user browses and books — see [consultations-user.md](./consultations-user.md#cycle))* | — | Booking a slot flips `is_booked` and creates a `ConsultationBooking` |
| 5 | Fulfillment screen loads | `GET /admin/consultation-bookings` | Filter by `filters[status]` |
| 6 | Admin opens one booking | `GET /admin/consultation-bookings/{consultation_booking}` | Shows payment status, attendance, join logs |
| 7 | Admin sets the meeting link (if the trainer hasn't) | `PUT /admin/consultation-bookings/{consultation_booking}/meeting-link` | Whoever sets it first owns future edits |
| 8 | *(user/trainer join; a scheduled job finalizes status to `completed`/`no_show` once the session's time window passes — see courses-trainer.md's timer sweep for the analogous pattern)* | — | Booking lifecycle is entirely automatic/self-service past this point — admin never manually changes `status` |
| 9 | Retire an offering or type once unused | `DELETE /admin/trainer-consultants/{trainer_consultant}` / `DELETE /admin/consultation-types/{consultation_type}` | Both refuse deletion while referenced (bookings / active offerings respectively) |

---

## Consultation types

`ConsultationType::$searchableFields = ['is_visible', 'name', 'description', 'created_at']` · translated: `name`, `description`.

### `GET /admin/consultation-types` — List

**Query parameters**

| Key | Type | Example | Notes |
|---|---|---|---|
| `page`, `per_page`, `paginate=false` | — | — | max 100 |
| `search` | string | `financial` | `name`/`description` (translated) |
| `sort[created_at]` | `asc`\|`desc` | `desc` | or `sort[name]`/`sort[description]` (translation-aware), `sort[is_visible]` |
| `filters[is_visible]` | `1`\|`0` | `1` | plain equality |
| ~~`filters[name]`~~ / ~~`filters[description]`~~ | — | — | **500 — translated, no real column.** Use `search=` |

**200 OK:**
```json
{ "status": "success", "message": "Data retrieved successfully", "data": [ { "id": "ct-uuid", "name": "Financial Consultation", "description": "One-on-one financial planning session.", "is_visible": true } ] }
```

### `POST /admin/consultation-types` — Create

**Body:** `{ "is_visible": true, "en": {"name": "Financial Consultation", "description": "..."}, "ar": {"name": "استشارة مالية", "description": "..."} }`

| Field | Type | Required | Rules |
|---|---|---|---|
| `is_visible` | boolean | no | `sometimes` |
| `{en,ar}.name` | string | yes | max 150 |
| `{en,ar}.description` | string | no | — |

**201 Created** → same shape as the list row.

### `GET /admin/consultation-types/{consultation_type}` — Detail (same shape; no extra fields beyond the list row)

### `PUT /admin/consultation-types/{consultation_type}` — Update (same fields, `sometimes`)

### `DELETE /admin/consultation-types/{consultation_type}` — Delete

**422 — still offered by a trainer:**
```json
{ "status": "error", "message": "Cannot delete consultation type associated with trainer consultants.", "data": { "error_code": "BUSINESS_RULE_VIOLATED" } }
```

---

## Trainer consultant offerings

`TrainerConsultant::$searchableFields = ['trainer_id', 'consultation_type_id', 'is_available']` — no translated attributes, one custom scope: `scopeAvailable()` (`is_available = true`; not auto-applied on the admin index — admin sees unavailable offerings too).

### `GET /admin/trainer-consultants` — List

**Query parameters**

| Key | Type | Example | Notes |
|---|---|---|---|
| `page`, `per_page`, `paginate=false` | — | — | max 100 |
| `search` | string | — | LIKE across `trainer_id`/`consultation_type_id`/`is_available` — none of these are meaningfully text-searchable (no translated fields on this model at all) |
| `sort[...]` | — | — | any of the three `$searchableFields` |
| `filters[is_available]` | `1`\|`0` | `1` | plain equality |
| ~~`filters[trainer_id]`~~ / ~~`filters[consultation_type_id]`~~ | — | — | compare against the **internal integer** FK columns — not usable with the uuids the API returns elsewhere (no `scopeTrainer_id()`/`scopeConsultation_type_id()`, same caveat as Course's `category_id`/`trainer_id` — see [courses-admin.md](./courses-admin.md#how-the-courses-list-works-index-pipeline--get-admincourses-only)) |

**200 OK** (`TrainerConsultantResource`):
```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": [
    { "id": "tc-uuid", "trainer": { "id": "trainer-uuid", "name": "Dr. Farooq Al-Bateel" }, "consultation_type": { "id": "ct-uuid", "name": "Financial Consultation" }, "price": 150.0, "duration_minutes": 45, "is_available": true, "created_at": "2026-09-14 07:54:11" }
  ]
}
```

### `POST /admin/trainer-consultants` — Create an offering

**Body:** `{ "trainer_id": "{{trainer_id}}", "consultation_type_id": "{{consultation_type_id}}", "price": 150, "currency_country_id": "{{country_id}}", "duration_minutes": 45, "is_available": true }`

| Field | Type | Required | Rules |
|---|---|---|---|
| `trainer_id` | uuid | yes | `exists:trainer_profiles,uuid` |
| `consultation_type_id` | uuid | yes | `exists:consultation_types,uuid` |
| `price` | number | yes | `min:0` — **flat field, not a `prices[]` array** like Book/Course; one price only, no per-country overrides |
| `currency_country_id` | uuid **or** integer id | no | unusually accepts *either* form here (custom closure rule) — everywhere else in this API (`prices.*.country_id`, etc.) only the uuid form is accepted |
| `duration_minutes` | integer | yes | `min:1` |
| `is_available` | boolean | no | `sometimes` |

**422 — duplicate offering:**
```json
{ "status": "error", "message": "This trainer already offers this consultation type.", "data": { "error_code": "BUSINESS_RULE_VIOLATED" } }
```
**201 Created** → same shape as the list row.

### `GET /admin/trainer-consultants/{trainer_consultant}` — Detail

Adds `slots[]` (`{id, starts_at, is_booked}`) when loaded.

### `PUT /admin/trainer-consultants/{trainer_consultant}` — Update (same fields, `sometimes`; the duplicate-pair check excludes the row being updated)

### `DELETE /admin/trainer-consultants/{trainer_consultant}` — Delete

**422 — has bookings:**
```json
{ "status": "error", "message": "Cannot delete a trainer consultant that has existing bookings.", "data": { "error_code": "BUSINESS_RULE_VIOLATED" } }
```

---

## Bookable slots

### `GET /admin/trainer-consultants/{trainer_consultant}/slots` — List, ordered by `starts_at`

**Path variables:** `trainer_consultant`. **Query parameters:** optional `from` / `to` (`YYYY-MM-DD`, inclusive UTC days, `to >= from`) and `paginate` / `per_page` / `page`; see [consultation-slots-date-filter.md](./consultation-slots-date-filter.md). Otherwise the full list (past, future, booked, and open — unlike the user-facing slot picker, which shows only open future slots).

**200 OK:** `[{ "id": "slot-uuid", "starts_at": "2026-10-05 14:00:00", "is_booked": false }]`

### `POST /admin/trainer-consultants/{trainer_consultant}/slots` — Publish slots (bulk dates × times)

**Path variables:** `trainer_consultant`. **Body:** `{ "dates": ["2026-10-05", "2026-10-06"], "times": ["10:00", "14:00"] }`

| Field | Type | Required | Rules |
|---|---|---|---|
| `dates` | array | yes | `min:1` |
| `dates.*` | date | yes | `after_or_equal:today` |
| `times` | array | yes | `min:1` |
| `times.*` | time | yes | `date_format:H:i` |

Publishes the **cross product** of every date × time (2 dates × 2 times above = up to 4 slots) in one call. Silently **skips** — rather than failing the whole request — any pair that's already past or already published for this offering; the response only contains the slots actually created (could be `[]` if every combination already existed or was in the past).

**201 Created:** `[{ "id": "slot-uuid", "starts_at": "2026-10-05 10:00:00", "is_booked": false }, ...]`

### `DELETE /admin/trainer-consultants/{trainer_consultant}/slots/{slot}` — Remove an unbooked slot

**Path variables:** `trainer_consultant`, `slot`. **404** if `slot` doesn't belong to `trainer_consultant`.

**422 — already booked:**
```json
{ "status": "error", "message": "This slot is already booked and cannot be removed.", "data": { "error_code": "BUSINESS_RULE_VIOLATED" } }
```
**200 OK:** `{ "status": "success", "message": "Data removed successfully", "data": null }`

---

## Consultation bookings (read-only, plus meeting-link)

`ConsultationBooking::$searchableFields = ['user_id', 'trainer_consultant_id', 'status', 'scheduled_at']` — no translated attributes, no custom scopes. **No `store`/`update`/`destroy`** — `POST`/`PUT`/`PATCH`/`DELETE` on either the collection or a single booking are routed straight to `abort(404)`.

### `GET /admin/consultation-bookings` — List

**Query parameters**

| Key | Type | Example | Notes |
|---|---|---|---|
| `page`, `per_page`, `paginate=false` | — | — | max 100 |
| `search` | string | — | LIKE across all four `$searchableFields` — none are meaningfully text-searchable (no translated attributes) |
| `sort[scheduled_at]` | `asc`\|`desc` | `asc` | or `sort[status]`, `sort[user_id]`, `sort[trainer_consultant_id]` |
| `filters[status]` | enum | `upcoming` | **`upcoming`\|`completed`\|`no_show`** — the only three statuses this model uses; finalized automatically by a scheduled job once a session's time window passes, or immediately by the trainer's `finish` action |
| ~~`filters[user_id]`~~ / ~~`filters[trainer_consultant_id]`~~ | — | — | internal integer FKs — not usable with the API's uuids |

**200 OK** (`ConsultationBookingResource`):
```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": [
    { "id": "cb-uuid", "consultation_type": "Financial Consultation", "user": { "id": "u-uuid", "name": "Sara Ahmed" }, "trainer": { "id": "trainer-uuid", "name": "Dr. Farooq Al-Bateel" }, "scheduled_at": "2026-10-05 14:00:00", "duration_minutes": 45, "price": 150.0, "status": "upcoming" }
  ]
}
```

### `GET /admin/consultation-bookings/{consultation_booking}` — Detail

Adds: `meeting_link`, `notes` (trainer's session notes), `user_attended`, `trainer_attended` (booleans — both must be `true` for the auto-finalize job to mark `completed` rather than `no_show`), `payment_status`, `join_logs[]` (`{party: "user"|"trainer", joined_at}` — one row per side's join, unlike course-session attendance which has just one row per student).

### `PUT /admin/consultation-bookings/{consultation_booking}/meeting-link` — Set/update the meeting link

**Body:** `{ "meeting_link": "https://meet.example.com/xyz" }` — `required, url, max:255`.

Whoever sets it first owns future edits. **403 — the trainer already owns it:**
```json
{ "status": "error", "message": "Only the trainer who set this link can edit it.", "data": { "error_code": "PERMISSION_DENIED" } }
```
**200 OK** → the full booking detail (`ConsultationBookingDetailsResource`) with the new `meeting_link`.
