# Training Programs API — Users (Trainees)

Public browsing, direct-checkout enrollment (no cart), "برامجي التدريبية" (my programs) with derived progress tabs, and live-session join.

- **Base path:** `{{base_url}}/user` (e.g. `http://localhost:8000/api/v1/user`)
- **Auth:** browse is public; everything from **enroll** onward requires `Authorization: Bearer {{user_token}}` (`auth:api, active, user_type:user`)
- **Source:** `app/Http/Controllers/Api/V1/Client/TrainingProgramController.php`, `app/Services/Client/TrainingProgramService.php`, `app/Services/Shared/CertificateService.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).

**`GET /user/programs` runs through the real index pipeline** (unlike the courses domain's `GET /user/courses`, which is a hand-written query with zero params) — see its section for the exact filters. `GET /user/my-programs` (via `fetch_my_programs`) does **not** — it's a fully custom, in-memory-paginated query.

**Pricing** — resolved from the `X-Country` header against the program's `prices[]`: a matching country's row, else the admin-flagged `is_default` row, else the first price row at all. No header at all → straight to the default/first row (no IP/profile fallback the way the wider `GeoContext` chain works elsewhere). **A program with zero `prices` rows can be browsed but not enrolled in** — `enroll` 422s with "no price configured" rather than defaulting to free.

**Enrollment here is noticeably looser than course enrollment** — no typed error codes, and (unlike a course's live-session seat lock) no row-lock against a concurrent double-enroll racing the capacity check. See the error table below for the exact shapes; don't assume they match [courses-user.md](./courses-user.md)'s.

## Error codes used in this domain

| HTTP | error_code | Thrown when |
|---|---|---|
| 404 | *(fail, `Data not found`)* | Program/session uuid doesn't resolve, or resolves to a not-yet-approved/hidden program |
| 403 | *(fail, no code — plain `HttpResponseException`, unlike the course domain's typed `AuthorizationDeniedException` for the same situation)* | `joinSession` without being enrolled in the program |
| 422 | *(fail, no code)* | Already enrolled; program fully booked (`capacity` reached); program has no price configured (all three via `enroll`); `joinSession` on a non-`online` session, or one that isn't live right now |
| 422 | *(fail, standard validation)* | `EnrollProgramRequest` |

---

## Cycle

| # | When | Guard | Method & Endpoint |
|---|---|---|---|
| 1 | Training section opens | Public | `GET /user/programs` |
| 2 | Program detail | Public | `GET /user/programs/{program}` |
| 3 | Enroll (direct checkout — no cart) | User | `POST /user/programs/{program}/enroll` — `{payment_method?}` |
| 4 | Confirmation | User | *(response of step 3)* |
| 5 | برامجي التدريبية | User | `GET /user/programs/fetch_my_programs?tab=ongoing` |
| 6 | Online session goes live — join | User | `POST /user/programs/{program}/sessions/{session}/join` |
| 7 | *(onsite attendance is trainer-marked, not self-service — see [training-programs-trainer.md](./training-programs-trainer.md#post-trainerprogramsprogramsessionssessionattendance--mark-onsite-attendance--trainer))* | — | — |
| 8 | *(certificate auto-issued once `certificate_attendance_threshold` is cleared)* | User | `GET /user/certificates` → `GET /user/certificates/{certificate}` |

---

## Browse & enrollment

### `GET /user/programs` — Public browse list

**Query parameters**

| Key | Type | Example | Notes |
|---|---|---|---|
| `page`, `per_page`, `paginate=false` | — | — | max 100 |
| `search` | string | `financial` | `name`/`description` (translated, current locale) |
| `sort[created_at]` | `asc`\|`desc` | `desc` | or `sort[name]`/`sort[description]` (translation-aware), etc. — same `$searchableFields` as admin |
| `filters[attendance_mode]` | enum | `hybrid` | `online`\|`onsite`\|`hybrid` |
| ~~`filters[category_id]`~~ / ~~`filters[trainer_id]`~~ | — | — | not usable with uuids (same caveat throughout this API) |
| **Header** `X-Country` | string | `EG` | pricing country |

Always scoped to `visible()` **and** `content_status = approved` (stricter than the admin list, which shows everything).

**200 OK** (`TrainingProgramResource`):
```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": [
    {
      "id": "p1a2...-uuid", "name": "Financial Leadership Program", "description": "A 6-week hybrid program for finance managers.",
      "category": {"id": "cat-uuid", "name": "Finance"}, "trainer": {"id": "trainer-uuid", "name": "Dr. Farooq Al-Bateel", "bio": "...", "photo": "https://cdn.example.com/trainer.jpg"},
      "target_audience": "Finance managers and senior analysts.", "experience_level": "Intermediate",
      "attendance_mode": "hybrid", "start_date": "2026-11-02", "duration_hours": 30, "image": "https://cdn.example.com/program.jpg",
      "price": 499.0, "has_certificate": true, "capacity": 25, "available_seats": 7, "participants_count": 18,
      "rating": { "average": 4.6, "count": 9 }
    }
  ]
}
```
`available_seats` is `null` when `capacity` is uncapped; `price` is `null` if the program somehow has no price row (browsable, not enrollable — see Conventions).

### `GET /user/programs/{program}` — Detail

**Path variables:** `program`. Adds (`TrainingProgramDetailsResource`): `demo_video`, `demo_text`, `goals`, `content`, `subscription_terms`, `certificate_terms`, `refund_policy`, `is_enrolled` (present only meaningfully for signed-in users — `false` for guests), `sessions[]` (`SessionResource` — see below), `materials[]`.

### `POST /user/programs/{program}/enroll` — **User**

**Path variables:** `program`. **Body:** `{ "payment_method": "wallet" }` — optional, `nullable, string, max:50`.

Creates an `Enrollment` (`status: "active"`) and an instant-success `Payment` at the resolved price. **Not idempotent the way course enrollment is** — a second attempt doesn't return the existing enrollment, it 422s:

```json
{ "status": "fail", "message": "You are already enrolled in this program.", "data": null }
{ "status": "fail", "message": "This program is fully booked.", "data": null }
{ "status": "fail", "message": "This program has no price configured.", "data": null }
```
**201 Created** (`EnrollmentResource`):
```json
{
  "id": "enr-uuid", "status": "active", "program": { "id": "p1a2...-uuid", "name": "Financial Leadership Program" },
  "enrolled_at": "2026-09-14 07:54:11",
  "payment": { "amount": 499.0, "status": "success", "transaction_reference": "9f2e...-uuid", "paid_at": "2026-09-14 07:54:20" }
}
```

### `GET /user/programs/fetch_my_programs` — برامجي التدريبية

**Query parameters**

| Key | Type | Example | Notes |
|---|---|---|---|
| `tab` | enum | `ongoing` | `all` (default)\|`upcoming`\|`ongoing`\|`completed` — **derived**, not a stored column: `upcoming` = no session has started yet (or none are scheduled at all); `completed` = every session's date has passed; `ongoing` = anything in between |
| `page`, `per_page` | — | — | max 100 — paginated **in PHP** over an already-fetched collection (every enrolled program is loaded, then sliced), not a SQL `LIMIT`/`OFFSET` |
| `paginate=false` | — | — | skip pagination entirely, return every enrolled program (all tabs already resolved) in one array |

Registered *before* the `{program}` show route so the literal `fetch_my_programs` segment is never swallowed as a program uuid.

**200 OK** (`MyProgramResource`):
```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": [
    {
      "id": "p1a2...-uuid", "name": "Financial Leadership Program", "attendance_mode": "hybrid", "start_date": "2026-11-02",
      "duration_hours": 30, "image": "https://cdn.example.com/program.jpg",
      "trainer": { "id": "trainer-uuid", "name": "Dr. Farooq Al-Bateel", "photo": "https://cdn.example.com/trainer.jpg" },
      "status": "ongoing",
      "progress": { "sessions_attended": 3, "sessions_total": 8, "percent": 37.5 }
    }
  ]
}
```

---

## Live sessions

### `SessionResource` shape (embedded in program detail and `joinSession`'s response)

```json
{ "id": "sess-uuid", "mode": "online", "session_date": "2026-11-09", "start_time": "10:00", "duration_minutes": 120, "location": null, "meeting_link": null, "is_live": false, "sort_order": 1 }
```
`meeting_link` is populated **only** when `is_enrolled && is_live` are both true for the requesting user — a browsing visitor and an enrolled trainee outside the live window both see `null`, indistinguishably.

### `POST /user/programs/{program}/sessions/{session}/join` — **User**

**Path variables:** `program`, `session`. No body.

**403 — not enrolled** (plain `fail`, **not** the typed `AuthorizationDeniedException` course sessions use for the equivalent case):
```json
{ "status": "fail", "message": "You must be enrolled in this program to join its sessions.", "data": null }
```
**422 examples:**
```json
{ "status": "fail", "message": "Only online sessions can be joined.", "data": null }
{ "status": "fail", "message": "This session is not live right now.", "data": null }
```
Unlike a course's `POST /user/sessions/{session}/join` (no time-window restriction at all — joinable any time once scheduled), a **program** session must actually be within its live window right now, or this 422s. Successful join marks attendance (`marked_by: "system"`) and re-checks certificate auto-issuance eligibility.

**200 OK** → `SessionResource` (with `meeting_link` now populated, since the call just proved both conditions).

---

## Certificates (issued for programs only — see [training-programs-admin.md § Certificates](./training-programs-admin.md#certificates))

### `GET /user/certificates` — My certificates, paginated (`per_page` only, no `search`/`filters`; newest `issued_at` first)

### `GET /user/certificates/{certificate}` — One of mine — **404** if it isn't (scoped to the caller, not a typed error)
