# Consultations API — Trainer

Trainer self-service on consultations booked with them (join, finish, notes, meeting link) and on their own consultant offerings (price/duration, bookable slots).

- **Base path:** `{{base_url}}/trainer` (e.g. `http://localhost:8000/api/v1/trainer`)
- **Auth:** `Authorization: Bearer {{trainer_token}}`, plus `user_type:trainer` middleware on both route groups below
- **Source:** `app/Http/Controllers/Api/V1/Trainer/{TrainerConsultation,TrainerConsultantOffering}Controller.php`, `app/Services/Trainer/{ConsultationService,ConsultantOfferingService}.php`, `app/Services/Shared/SlotPublisher.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`.

**Ownership guard shape — read this before assuming a generic 403.** Every write here resolves the caller's own `TrainerProfile` first (`ResolvesTrainer::trainerProfile()`) and then checks the target row belongs to them — both failures throw the typed `AuthorizationDeniedException`, which renders as **`error` + `PERMISSION_DENIED`**, not the plain `fail` shape:
```json
{ "status": "error", "message": "This consultation is not assigned to you.", "data": { "error_code": "PERMISSION_DENIED" } }
```

**`GET /trainer/consultations` supports real filtering; nothing else in this document does.** It's the one list endpoint here that isn't a plain hand-written query — see its own section for the exact params. `GET /trainer/consultant-offerings` returns its full result set, no `page`/`search`/`filters`. `GET /trainer/consultant-offerings/{trainerConsultant}/slots` only takes an optional `from` / `to` day range (see [consultation-slots-date-filter.md](./consultation-slots-date-filter.md)).

**Two permission-gated actions.** Editing an offering's price/duration and publishing new slots both require a per-trainer capability flag an admin grants separately (`edit_pricing`, `manage_consultation_slots`) — a trainer without the flag gets the same `PERMISSION_DENIED` shape as an ownership failure, not a validation error.

## Error codes used in this domain

| HTTP | error_code | Thrown when |
|---|---|---|
| 403 | `PERMISSION_DENIED` | Caller has no trainer profile; consultation/offering not assigned to this trainer; missing `edit_pricing` (update offering) or `manage_consultation_slots` (publish slots); meeting link already owned by an admin |
| 422 | `BUSINESS_RULE_VIOLATED` | `join`/`finish` on a consultation whose status is no longer `upcoming` |
| 422 | *(fail, standard validation)* | `ConsultationNotesRequest`, `ConsultationMeetingLinkRequest`, `TrainerConsultantOfferingRequest`, `TrainerConsultantSlotRequest` |
| 404 | *(fail, `Data not found`)* | Booking/offering uuid doesn't resolve at all (as opposed to belonging to someone else, which is the 403 above) |

---

## Cycle — consultations assigned to me

| # | When | Method & Endpoint | Notes |
|---|---|---|---|
| 1 | Upcoming consultations list | `GET /trainer/consultations` | Filter by `filters[status]` |
| 2 | Open one | `GET /trainer/consultations/{booking}` | Includes the trainee's phone/email |
| 3 | Set the meeting link ahead of time (if the admin hasn't) | `PUT /trainer/consultations/{booking}/meeting-link` | Whoever sets it first owns future edits |
| 4 | At session time, join | `POST /trainer/consultations/{booking}/join` | Marks `trainer_attended`; status stays `upcoming` |
| 5 | End the session | `POST /trainer/consultations/{booking}/finish` | Status becomes `completed` (if the user also attended) or `no_show` — otherwise a scheduled job finalizes it automatically once the time window passes |
| 6 | Record session notes | `PUT /trainer/consultations/{booking}/notes` | Free text, visible to admin, never to the trainee |

**Managing my own offerings** (independent of the booking cycle above):

| # | When | Method & Endpoint | Notes |
|---|---|---|---|
| 1 | See what I offer | `GET /trainer/consultant-offerings` | Only offerings an admin assigned to me |
| 2 | Adjust price/duration | `PUT /trainer/consultant-offerings/{trainerConsultant}` | Requires `edit_pricing` |
| 3 | See one offering's slots for a period | `GET /trainer/consultant-offerings/{trainerConsultant}/slots?from=&to=` | Past + future, booked + open; no permission beyond ownership |
| 4 | Open up more bookable time | `POST /trainer/consultant-offerings/{trainerConsultant}/slots` | Requires `manage_consultation_slots`; same bulk dates × times semantics as the admin endpoint |

---

## Consultations assigned to me

### `GET /trainer/consultations` — List

**Query parameters**

| Key | Type | Example | Notes |
|---|---|---|---|
| `page` | integer | `1` | Laravel's implicit paginator param |
| `per_page` | integer | `20` | clamped `[1, 100]`, default 15 |
| `filters[status]` | enum | `upcoming` | **bare `filters.status` key, read directly** (not run through the generic index pipeline) — `upcoming`\|`completed`\|`no_show` |

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

**200 OK** (`ConsultationBookingResource`):
```json
{ "id": "cb-uuid", "consultation_type": "Financial Consultation", "user": "Sara Ahmed", "scheduled_at": "2026-10-05 14:00:00", "duration_minutes": 45, "status": "upcoming" }
```

### `GET /trainer/consultations/{booking}` — Detail

**Path variables:** `booking`. Adds `meeting_link`, `notes`, `user_phone`, `user_email` (`ConsultationBookingDetailsResource`).

**403** if not assigned to this trainer:
```json
{ "status": "error", "message": "This consultation is not assigned to you.", "data": { "error_code": "PERMISSION_DENIED" } }
```

### `POST /trainer/consultations/{booking}/join` — **Trainer**

**Path variables:** `booking`. No body. Sets `trainer_attended = true` and logs a `ConsultationJoinLog` row (`party: "trainer"`) — distinct from `finish`, which additionally closes the booking out.

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

### `POST /trainer/consultations/{booking}/finish` — **Trainer**

**Path variables:** `booking`. No body. Sets `trainer_attended = true` and **transitions `status`**: `completed` if the user also attended (`user_attended` was already `true`, e.g. from their own `join`), otherwise `no_show`. This is the trainer explicitly closing the session out — a scheduled job (`consultations:finalize-status`) would otherwise catch it automatically once `scheduled_at + duration_minutes` passes, using the same completed/no_show logic based on both parties' attendance.

**422 — not upcoming:** same shape as `join` above.

**200 OK** → `ConsultationBookingDetailsResource` with the new `status`.

### `PUT /trainer/consultations/{booking}/notes` — **Trainer**

**Path variables:** `booking`. **Body:** `{ "notes": "Discussed Q3 cash flow projections..." }` — `required, string, max:2000`. No status/attendance restriction — can be set/updated at any time, including after the booking is finalized.

### `PUT /trainer/consultations/{booking}/meeting-link` — **Trainer**

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

**403 — an admin already owns it:**
```json
{ "status": "error", "message": "Only an admin who set this link can edit it.", "data": { "error_code": "PERMISSION_DENIED" } }
```
**200 OK** → `ConsultationBookingDetailsResource` (mirror of [the admin endpoint](./consultations-admin.md#put-adminconsultation-bookingsconsultation_bookingmeeting-link--setupdate-the-meeting-link) — the ownership rule runs symmetrically both ways).

---

## My consultant offerings

### `GET /trainer/consultant-offerings` — My own offerings

No query parameters — the trainer's full offering list, with `consultationType` and `slots` eager-loaded. The embedded `slots` are unfiltered; use the slots endpoint below for a date range.

**200 OK** (`TrainerConsultantOfferingResource`):
```json
{
  "id": "tc-uuid", "consultation_type": "Financial Consultation", "price": 150.0, "duration_minutes": 45, "is_available": true,
  "slots": [ { "id": "slot-uuid", "starts_at": "2026-10-05 14:00:00", "is_booked": false } ]
}
```

### `PUT /trainer/consultant-offerings/{trainerConsultant}` — Update price/duration — requires `edit_pricing`

**Path variables:** `trainerConsultant`. **Body:** `{ "price": 175, "duration_minutes": 60 }` — both optional (`sometimes`); only `price`/`duration_minutes` are writable here (not `is_available`, `trainer_id`, or `consultation_type_id` — those stay admin-only via [the admin endpoint](./consultations-admin.md#put-admintrainer-consultantstrainer_consultant--update)).

| Field | Type | Required | Rules |
|---|---|---|---|
| `price` | number | no | `sometimes, numeric, min:0` |
| `duration_minutes` | integer | no | `sometimes, integer, min:1` |

**403 — not this trainer's offering:**
```json
{ "status": "error", "message": "This consultation offering is not yours.", "data": { "error_code": "PERMISSION_DENIED" } }
```
**403 — missing the capability flag:**
```json
{ "status": "error", "message": "You are not permitted to edit your consultation pricing.", "data": { "error_code": "PERMISSION_DENIED" } }
```
*(ownership is checked before the permission flag — a non-owned uuid always 403s with the "not yours" message, never leaks whether the real owner has the flag)*

**200 OK** → `TrainerConsultantOfferingResource`.

### `GET /trainer/consultant-offerings/{trainerConsultant}/slots` — My offering's slots

**Path variables:** `trainerConsultant`. **Query parameters:** optional `from` / `to` (`YYYY-MM-DD`, inclusive UTC days, `to >= from`); see [consultation-slots-date-filter.md](./consultation-slots-date-filter.md). Every slot on the offering (past, future, booked, open), ordered by `starts_at`. No capability flag needed, only ownership (403 `PERMISSION_DENIED` "This consultation offering is not yours." otherwise).

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

### `POST /trainer/consultant-offerings/{trainerConsultant}/slots` — Publish slots — requires `manage_consultation_slots`

**Path variables:** `trainerConsultant`. **Body:** `{ "dates": ["2026-10-05", "2026-10-06"], "times": ["10:00", "14:00"] }` — identical validation and skip-past/skip-duplicate bulk semantics as [the admin slot endpoint](./consultations-admin.md#post-admintrainer-consultantstrainer_consultantslots--publish-slots-bulk-dates--times) (same `SlotPublisher` under the hood).

**403 — missing the capability flag:**
```json
{ "status": "error", "message": "You are not permitted to publish new consultation slots.", "data": { "error_code": "PERMISSION_DENIED" } }
```
**201 Created** → the newly created `SlotResource[]` (`{id, starts_at, is_booked}` — same shape as admin's, `is_booked` always `false` for a freshly published slot).
