# Consultations API — Users

Public browsing of consultants and consultation types, booking a slot (a direct single-item checkout — no cart, mirrors training-program enrollment), and the trainee's own bookings.

- **Base path:** `{{base_url}}/user` (e.g. `http://localhost:8000/api/v1/user`)
- **Auth:** browse is public; booking and "my bookings" require `Authorization: Bearer {{user_token}}` (`auth:api, active, user_type:user`)
- **Source:** `app/Http/Controllers/Api/V1/Client/{ConsultationBooking,Consultant,ConsultationType,TrainerConsultantSlot}Controller.php`, `app/Services/Client/{ConsultationBookingService,ConsultantService,ConsultationTypeService}.php`

## Conventions

Same envelope conventions as [books-dashboard.md](./books-dashboard.md#conventions). Headers: `Accept: application/json`, `Content-Type: application/json`, `Accept-Language: en|ar`, plus `X-Country` (optional — see pricing note below).

**Pricing** — a trainer's consultation price has a single default (`trainer_consultants.price`/`currency_country_id`) plus optional per-country overrides (`ItemPriceOverride`, same generic table Book uses). Resolution: `X-Country` header → matching override if one exists → the offering's default price. There is no `country_id` query parameter.

**404 vs the typed 403/404 you might expect.** Two endpoints here use typed `EntityNotFoundException` (`error` + `ENTITY_NOT_FOUND`) instead of the generic `fail` 404 — see the error table below; don't assume every "not found" in this file looks the same.

## Error codes used in this domain

| HTTP | error_code | Thrown when |
|---|---|---|
| 404 | *(fail, `Data not found`)* | The offering (`trainer_consultant_id`) or slot (`slot_id`) named in a booking request doesn't resolve, or resolves to an unavailable/other-offering slot |
| 404 | `ENTITY_NOT_FOUND` | `GET /user/consultants/{trainer}` for a trainer with no visible offerings, or `GET /user/consultation-bookings/{booking}` for a booking that isn't this user's (typed exception — `error`, not `fail`) |
| 422 | `BUSINESS_RULE_VIOLATED` | Booking a slot that's already booked or already past (checked twice: once before the transaction, once atomically inside it, to close the race between two concurrent bookings for the same slot); `join` on a booking whose status is no longer `upcoming` |
| 422 | *(fail, standard validation)* | `BookConsultationRequest` |

---

## Cycle

| # | When | Guard | Method & Endpoint |
|---|---|---|---|
| 1 | "Our Consultants" section opens | Public | `GET /user/consultants` |
| 2 | Filter by consultation type | Public | `GET /user/consultants?type={{consultation_type_uuid}}` |
| 3 | Consultant profile opened | Public | `GET /user/consultants/{trainer}` — lists their offerings (type, price, duration) |
| 4 | Or browse types directly first | Public | `GET /user/consultation-types` |
| 5 | Pick a time | Public | `GET /user/trainer-consultants/{trainer_consultant}/slots` — open, future slots only |
| 6 | Book | User | `POST /user/consultation-bookings` — `{trainer_consultant_id, slot_id, payment_method?}` — instant-success checkout, no cart |
| 7 | Booking confirmation | User | *(response of step 6)* |
| 8 | My bookings list | User | `GET /user/consultation-bookings` |
| 9 | Open one | User | `GET /user/consultation-bookings/{booking}` |
| 10 | At session time, join | User | `POST /user/consultation-bookings/{booking}/join` |
| 11 | *(trainer joins/finishes their side — see [consultations-trainer.md](./consultations-trainer.md#cycle--consultations-assigned-to-me))* | — | Booking auto-finalizes to `completed`/`no_show` once both sides' attendance and the time window are known |

---

## Browse (public)

### `GET /user/consultation-types` — List visible types

Standard index params — goes through the shared pipeline (see [books-dashboard.md](./books-dashboard.md#how-list-endpoints-work-index-pipeline)) scoped to `visible()`. `ConsultationType::$searchableFields = ['is_visible', 'name', 'description', 'created_at']`; `search=` matches `name`/`description` (translated); `filters[name]`/`filters[description]` would 500 (same translated-field pitfall as everywhere else — use `search=` instead).

**200 OK:** `[{ "id": "ct-uuid", "name": "Financial Consultation", "description": "One-on-one financial planning session." }]`

### `GET /user/consultants` — مستشارينا (consultants with at least one visible, available offering)

**Query parameters**

| Key | Type | Example | Notes |
|---|---|---|---|
| `per_page` | integer | `15` | clamped `[1, 100]` |
| `type` | uuid | `{{consultation_type_uuid}}` | **bare query key**, not `filters[type]` — narrows to trainers who offer that specific (visible) consultation type |

No `search`/`sort`. Paginated response wrapped `{"trainer_profiles": [...]}`.

**200 OK** (`ConsultantResource`):
```json
{ "id": "trainer-uuid", "name": "Dr. Farooq Al-Bateel", "bio": "15 years in corporate finance advisory.", "rating": 4.7, "consultation_types": ["Financial Consultation", "Tax Advisory"] }
```

### `GET /user/consultants/{trainer}` — Consultant detail

**Path variables:** `trainer`. Adds `offerings[]` (`{id, consultation_type, price, duration_minutes}` — `id` here is the **`trainer_consultant` uuid**, the value you pass as `trainer_consultant_id` when booking, not the trainer's own uuid).

**404 (typed):**
```json
{ "status": "error", "message": "Consultant not found.", "data": { "error_code": "ENTITY_NOT_FOUND" } }
```

### `GET /user/trainer-consultants/{trainer_consultant}/slots` — Open slots for one offering

**Path variables:** `trainer_consultant`. **Query parameters:** optional `from` / `to` (`YYYY-MM-DD`, inclusive UTC days, `to >= from`); see [consultation-slots-date-filter.md](./consultation-slots-date-filter.md). Always `available()` (unbooked) **and** future, ordered by `starts_at` — contrast with the admin/trainer slot lists, which show every slot including past and booked ones.

**200 OK:** `[{ "id": "slot-uuid", "starts_at": "2026-10-05 14:00:00" }]` — note: **no `is_booked` key** here (it would always be `true`... never — every row returned is by definition unbooked, so the field is simply omitted rather than redundantly always-`false`).

---

## Booking — **User**

### `POST /user/consultation-bookings` — Book a slot (direct checkout, no cart)

**Body:** `{ "trainer_consultant_id": "{{trainer_consultant_id}}", "slot_id": "{{slot_id}}", "payment_method": "wallet" }`

| Field | Type | Required | Rules |
|---|---|---|---|
| `trainer_consultant_id` | uuid | yes | `exists:trainer_consultants,uuid` |
| `slot_id` | uuid | yes | `exists:trainer_consultant_slots,uuid` |
| `payment_method` | string | no | `nullable, max:50` |

The slot must belong to the named offering and be both available and not yet booked; the resolved offering must itself be `is_available: true`. The final check runs as an atomic conditional `UPDATE ... WHERE is_booked = false` inside the transaction, so two simultaneous booking attempts on the same slot can't both succeed — the loser gets:

**422 — slot taken (either the pre-check or the race-condition guard):**
```json
{ "status": "error", "message": "This slot is no longer available.", "data": { "error_code": "BUSINESS_RULE_VIOLATED" } }
```
**404** if either uuid simply doesn't resolve (wrong offering/slot pairing included): `{ "status": "fail", "message": "Data not found", "data": null }`.

**201 Created** (`ConsultationBookingResource`):
```json
{
  "id": "cb-uuid", "consultation_type": "Financial Consultation", "trainer": "Dr. Farooq Al-Bateel",
  "scheduled_at": "2026-10-05 14:00:00", "duration_minutes": 45, "price": 150.0, "status": "upcoming",
  "payment": { "status": "success", "transaction_reference": "9f2e...-uuid", "paid_at": "2026-09-14 07:54:20" }
}
```

### `GET /user/consultation-bookings` — My bookings

**Query parameters**

| Key | Type | Example | Notes |
|---|---|---|---|
| `page` | integer | `1` | — |
| `per_page` | integer | `20` | clamped `[1, 100]`, default 15 |
| `paginate` | `false`\|`0` | `false` | unpaginated path, capped at 500 rows |
| `filters[status]` | enum | `upcoming` | **bare `filters.status` key**, same as the trainer list — `upcoming`\|`completed`\|`no_show` |

No `search`/`sort`. Always ordered by `scheduled_at`, newest first. Paginated response wrapped `{"consultation_bookings": [...]}`.

### `GET /user/consultation-bookings/{booking}` — Detail

**Path variables:** `booking`. Adds `meeting_link`, `notes` (the trainer's session notes — **visible to the trainee** here, unlike course materials which are one-way; this is the one place a trainer's private-feeling notes field actually surfaces to the other party) to the list shape.

**404 (typed — not another user's booking, not simply missing):**
```json
{ "status": "error", "message": "Consultation booking not found.", "data": { "error_code": "ENTITY_NOT_FOUND" } }
```

### `POST /user/consultation-bookings/{booking}/join` — **User**

**Path variables:** `booking`. No body. Sets `user_attended = true`, logs a `ConsultationJoinLog` row (`party: "user"`). Mirrors the trainer's `join` — neither party's `join` alone changes `status`; that only happens via the trainer's `finish` or the scheduled finalize job.

**422 — not upcoming:**
```json
{ "status": "error", "message": "This consultation is no longer upcoming.", "data": { "error_code": "BUSINESS_RULE_VIOLATED" } }
```
**200 OK** → the full booking detail.
