# Training Programs API — Admin Dashboard

Admin management of training programs (قسم التدريب): CRUD, the content-review workflow, session scheduling (single + bulk), attendance oversight, and manual certificate issuance.

- **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.programs.store` → `programs.store`)
- **Source:** `app/Http/Controllers/Api/V1/Admin/{Program,ProgramSession,Certificate}Controller.php`, `app/Services/Admin/{ProgramService,ClassSessionService}.php`, `app/Services/Shared/CertificateService.php`, `app/Models/TrainingProgram.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.

**Training programs largely mirror recorded courses' admin shape** (see [courses-admin.md](./courses-admin.md)) — a review workflow (`content_status`), per-country `prices[]`, media-hash images/video, a training timer. The concrete differences that matter when integrating both: programs have **no live/recorded split** (one `ProgramRequest` for everything), sessions are a nested **`apiResource`** rather than the courses domain's bespoke Phase A/B endpoints, and **certificates are issued per program**, not per course (course certificates aren't wired up yet anywhere in this codebase).

## Error codes used in this domain

| HTTP | error_code | Thrown when |
|---|---|---|
| 404 | *(fail, `Data not found`)* | Program/session uuid doesn't resolve |
| 422 | *(fail, standard validation)* | `ProgramRequest`, `UpdateContentStatusRequest`, `ProgramSessionRequest`, `ProgramSessionBulkRequest`, `IssueCertificateRequest` — including the cross-field rule below |
| 422 | *(fail, no code — plain validation-style message, not a typed error)* | A session's `mode` is incompatible with the program's `attendance_mode` (an `online` program only takes `online` sessions, `onsite` only `onsite`, `hybrid` takes either) — attached to the `mode` field (or `sessions.{i}.mode` in bulk) |
| 422 | *(fail, no code)* | `POST .../certificates` when the program doesn't offer a certificate, or the named student isn't enrolled — plain `HttpResponseException`, not `BusinessRuleViolation`, so no `error_code` even though it's a genuine business rule |
| 500 | *(error, "Database error")* | `filters[name]` / `filters[description]` on `GET /admin/programs` — translated attributes, no real column |

Unlike Book and Course, **`ProgramService` has no `beforeDestroy()` guard at all** — deleting a program with enrollments, sessions, or certificates is not blocked at the application layer.

---

## Cycle

### A. Build a program, review it, publish, run it

| # | When | Method & Endpoint | Notes |
|---|---|---|---|
| 1 | Admin creates the program shell | `POST /admin/programs` | `content_status` starts `draft`; assign `trainer_id`, `attendance_mode` |
| 2 | Schedule its sessions | `POST /admin/programs/{program}/sessions/bulk` | One call, several rows — all-or-nothing; each row's `mode` must fit `attendance_mode` |
| 3 | *(or one at a time)* | `POST /admin/programs/{program}/sessions` | Nested `apiResource` — same validation, one row per call |
| 4 | Move through review (optional — same states as courses) | `POST /admin/programs/{program}/content-status` | `draft → pending_review → needs_revision/approved` — a single admin-only status setter, no separate trainer submit/admin approve pair like courses has |
| 5 | Make it visible | `PUT /admin/programs/{program}` `{is_visible: true}` | Unlike courses, there's no dedicated `publish`/`publish-check` action or pre-flight validation — `is_visible` is just another field on the plain update |
| 6 | *(users enroll — see [training-programs-user.md](./training-programs-user.md#cycle))* | — | Direct checkout, no cart, no seat race beyond a simple `capacity` count check |
| 7 | Monitor what's live right now, across every program | `GET /admin/programs/sessions/live` | Registered ahead of the nested resource so the literal `sessions/live` segment isn't swallowed as a `{program}` uuid |
| 8 | Check one session's attendance | `GET /admin/programs/{program}/sessions/{session}/attendance` | Every student's record, however it was marked (self-join online, trainer-marked onsite, or admin) |
| 9 | Or the whole program's aggregate | `GET /admin/programs/{program}/sessions/attendance-summary` | Enrollment count, average attendance rate, completion rate |
| 10 | Issue a certificate manually (auto-issuance also fires after eligible attendance — see the trainer/user docs) | `POST /admin/programs/{program}/certificates` | Requires `has_certificate` on the program and the student to be enrolled; idempotent — re-issuing returns the existing certificate |
| 11 | See everyone who has one | `GET /admin/programs/{program}/certificates` | — |
| 12 | Who's enrolled | `GET /admin/enrolled-users?program_id=...` | Shared with course enrollments — see [courses-admin.md](./courses-admin.md#enrollments) |

---

## Programs

`TrainingProgram::$searchableFields = ['slug', 'category_id', 'trainer_id', 'attendance_mode', 'content_status', 'is_visible', 'name', 'description', 'created_at']` · translated: `name`, `description` (of the model's full translated set — `name`, `description`, `demo_text`, `goals`, `content`, `target_audience`, `experience_level`, `subscription_terms`, `certificate_terms`, `refund_policy` — only these two are searchable/filterable/sortable at all).

### `GET /admin/programs` — 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), `sort[slug]`, `sort[attendance_mode]`, `sort[content_status]`, `sort[is_visible]` |
| `filters[attendance_mode]` | enum | `hybrid` | `online`\|`onsite`\|`hybrid` |
| `filters[content_status]` | enum | `draft` | `draft`\|`pending_review`\|`needs_revision`\|`approved` |
| `filters[is_visible]` | `1`\|`0` | `1` | plain equality |
| `filters[slug]` | string | — | exact match |
| ~~`filters[category_id]`~~ / ~~`filters[trainer_id]`~~ | — | — | **not usable with the API's uuids** — same caveat as Course (no `scopeCategory_id()`/`scopeTrainer_id()`) |

**200 OK** (`ProgramResource`):
```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": [
    {
      "id": "p1a2...-uuid", "slug": "financial-leadership-program", "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" },
      "attendance_mode": "hybrid", "start_date": "2026-11-02", "duration_hours": 30, "capacity": 25,
      "has_quiz": false, "has_certificate": true, "certificate_attendance_threshold": 80,
      "content_status": "draft", "is_visible": false,
      "demo_video": "https://cdn.example.com/demo.mp4", "image": "https://cdn.example.com/program.jpg",
      "created_at": "2026-09-14 07:54:11"
    }
  ]
}
```

### `POST /admin/programs` — Create

**Body**
```json
{
  "category_id": "{{category_id}}", "trainer_id": "{{trainer_id}}", "attendance_mode": "hybrid",
  "duration_hours": 30, "capacity": 25, "has_quiz": false, "has_certificate": true,
  "certificate_attendance_threshold": 80, "content_status": "draft", "is_visible": false,
  "image": "{{media_hash}}", "demo_video": "{{media_hash}}", "training_timer_max_minutes": 1800,
  "prices": [{ "country_id": "{{country_id}}", "price": 499.0, "is_default": true }],
  "en": { "name": "Financial Leadership Program", "description": "...", "demo_text": "...", "goals": "...", "content": "...", "target_audience": "...", "experience_level": "Intermediate", "subscription_terms": "...", "certificate_terms": "...", "refund_policy": "..." },
  "ar": { "name": "برنامج القيادة المالية", "description": "...", "demo_text": "...", "goals": "...", "content": "...", "target_audience": "...", "experience_level": "متوسط", "subscription_terms": "...", "certificate_terms": "...", "refund_policy": "..." }
}
```

| Field | Type | Required | Rules |
|---|---|---|---|
| `category_id` | uuid | yes | `exists:categories,uuid` |
| `trainer_id` | uuid | yes | `exists:trainer_profiles,uuid` |
| `attendance_mode` | enum | yes | `online`\|`onsite`\|`hybrid` |
| `start_date` | date | no | `nullable` — **unlike Course, this is a plain writable field**, not derived from sessions; nothing keeps it in sync with the actual session calendar |
| `duration_hours` | integer | yes | `min:1` |
| `capacity` | integer | no | `nullable, min:1` — `null` means uncapped |
| `has_quiz` | boolean | no | `sometimes` — accepted but there is no program-quiz authoring surface anywhere in this API yet |
| `has_certificate` | boolean | no | `sometimes` |
| `certificate_attendance_threshold` | integer | no | `nullable, min:1, max:100` — % attendance that **auto-issues** a certificate (independent of the manual issue endpoint below) |
| `content_status` | enum | no | `draft`\|`pending_review`\|`needs_revision`\|`approved` |
| `is_visible` | boolean | no | `sometimes` |
| `demo_video` | media hash | no | video |
| `image` | media hash | no | image |
| `training_timer_max_minutes` | integer | no | `nullable, min:1` — عداد التدريب cap, same mechanic as courses |
| `prices` | array | no | `sometimes, array` — no "exactly/at most one default" cross-check is enforced by validation here (unlike Book/Course); the service just un-defaults every other row whenever one `is_default: true` is synced |
| `prices.*.country_id` | uuid **or** integer id | conditional | `required_with:prices, distinct` + custom closure accepting either form (same unusual flexibility as `TrainerConsultant.currency_country_id`) |
| `prices.*.price` | number | conditional | `required_with:prices, min:0` |
| `prices.*.is_default` | boolean | no | `sometimes` |
| `{en,ar}.name` | string | yes | max 200 |
| `{en,ar}.description`, `.demo_text`, `.goals`, `.content`, `.target_audience`, `.subscription_terms`, `.certificate_terms`, `.refund_policy` | string | no | — |
| `{en,ar}.experience_level` | string | no | max 100 |

**201 Created** → `ProgramDetailsResource` (same shape as `GET .../{program}` below).

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

**Path variables:** `program`. Adds (`ProgramDetailsResource`): `demo_text`, `goals`, `content`, `target_audience`, `experience_level`, `subscription_terms`, `certificate_terms`, `refund_policy`, `training_timer_max_minutes`, `prices[]` (`{country_id (uuid), country_name, currency_code, currency_symbol, price, is_default}`), `updated_at`.

### `PUT /admin/programs/{program}` — Update (same fields, `sometimes`)

**Path variables:** `program`. No content-status/type restrictions like Course's recorded/live split — every field is editable through this one endpoint regardless of current `content_status`.

### `POST /admin/programs/{program}/content-status` — Move through the review workflow

**Path variables:** `program`. **Body:** `{ "content_status": "approved" }` — `required, in:draft,pending_review,needs_revision,approved`.

Unlike Course's workflow (`submit-for-review`/`approve`/`request-revision` as separate typed transitions with `INVALID_STATE_TRANSITION` guards), this is a **single unrestricted setter** — any status can move to any other status in one call, admin-only (no trainer-initiated "submit for review" action exists for programs).

**200 OK:** `ProgramResource` with the new `content_status`.

### `DELETE /admin/programs/{program}` — Delete (no business-rule guard — see the note above)

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

**Body:** `{ "ids": ["{{program_id}}", ...] }`

---

## Sessions

`ClassSession::$searchableFields = ['owner_type', 'owner_id', 'mode', 'session_date']` — no translated attributes (`location` is translated but **not** searchable/filterable/sortable — it's simply absent from `$searchableFields`). Nested under `/admin/programs/{program}/sessions` as a standard `apiResource`, scoped server-side to `owner_type='program', owner_id=<{program}>` — a session uuid that belongs to a *different* program 404s even if the uuid itself is valid.

### `GET /admin/programs/{program}/sessions` — List

**Path variables:** `program`. **Query parameters**

| Key | Type | Example | Notes |
|---|---|---|---|
| `page`, `per_page`, `paginate=false` | — | — | max 100 |
| `search` | string | — | LIKE across `owner_type`/`owner_id`/`mode`/`session_date` — none meaningfully text-searchable |
| `sort[...]` | — | — | defaults to `sort_order` (not `created_at`, unlike most index endpoints) when no `?sort=` is given; explicit `sort[mode]`/`sort[session_date]` also work |
| `filters[mode]` | enum | `online` | `online`\|`onsite` |
| `filters[session_date]` | date | `2026-11-02` | exact-date equality |
| ~~`filters[owner_type]`~~ / ~~`filters[owner_id]`~~ | — | — | already fixed by the `{program}` scoping — redundant, and `owner_id` is an internal id anyway |

**200 OK** (`SessionResource`):
```json
{
  "id": "sess-uuid", "mode": "onsite", "session_date": "2026-11-02", "start_time": "10:00", "duration_minutes": 180,
  "meeting_link": null, "location": "Cairo HQ — Room 3", "sort_order": 0,
  "is_live": false, "trainer_joined_at": null, "trainer_attended": false, "attended_count": 0, "created_at": "2026-09-14 07:54:11"
}
```

### `POST /admin/programs/{program}/sessions` — Create one session

**Path variables:** `program`. **Body:** `{ "mode": "onsite", "session_date": "2026-11-02", "start_time": "10:00", "duration_minutes": 180, "en": {"location": "Cairo HQ — Room 3"}, "ar": {"location": "..."} }`

| Field | Type | Required | Rules |
|---|---|---|---|
| `mode` | enum | yes | `online`\|`onsite` — **must be compatible with the program's `attendance_mode`** (see error below) |
| `session_date` | date | yes | — |
| `start_time` | time | yes | `date_format:H:i` (note: `H:i`, not courses' `H:i:s`) |
| `duration_minutes` | integer | yes | `min:1` |
| `meeting_link` | url | no | `nullable` |
| `sort_order` | integer | no | `sometimes` |
| `{en,ar}.location` | string | no | max 255 |

**422 — mode/attendance_mode mismatch:**
```json
{ "status": "fail", "message": "This program (onsite) does not allow online sessions.", "data": null }
```
**201 Created** → `SessionResource`.

### `GET /admin/programs/{program}/sessions/{session}/attendance` — One session's full attendance roll

**Path variables:** `program`, `session`. No query parameters.

**200 OK** (`AttendanceResource[]`): `[{ "student": {"id": "u-uuid", "name": "Sara Ahmed"}, "attended": true, "joined_at": "2026-11-02 10:03:00", "marked_by": "system" }]` — `marked_by` is `"system"` (self-join), `"trainer"`, or `"trainer"` again for an admin correction (the admin view doesn't distinguish who did the marking beyond that stored value).

### `GET /admin/programs/{program}/sessions/attendance-summary` — Whole-program aggregate

**Path variables:** `program`. No query parameters.

**200 OK:**
```json
{ "program_id": "p1a2...-uuid", "sessions_total": 8, "total_enrolled": 25, "average_attendance_rate": 78.5, "completed": 6, "completion_rate": 24.0 }
```

### `POST /admin/programs/{program}/sessions/bulk` — Create several sessions at once

**Path variables:** `program`. **Body:** `{ "sessions": [ {"mode": "onsite", "session_date": "2026-11-02", "start_time": "10:00", "duration_minutes": 180}, {"mode": "online", "session_date": "2026-11-09", "start_time": "10:00", "duration_minutes": 120, "meeting_link": "https://meet.example.com/wk2"} ] }`

Every row follows the same field table as the single-create endpoint above (as `sessions.*.field`), including the per-row mode/`attendance_mode` check. **All-or-nothing** — one invalid row (including a bad mode) fails validation for the *entire* request before anything is saved; there is no partial-success response like the consultation slot publisher.

**422 — one row's mode is wrong** (error attaches to that row's index):
```json
{ "status": "fail", "message": "This program (onsite) does not allow online sessions.", "data": null }
```
**201 Created** → `SessionResource[]` for every row created.

### `GET /admin/programs/sessions/live` — Every currently-live session, across all programs

No path/query parameters. Registered *before* the nested `apiResource` so this literal path isn't captured as `{program}`.

**200 OK** (`LiveSessionResource[]` — carries its own `program`/`trainer` context since it spans every program):
```json
[{ "id": "sess-uuid", "program": {"id": "p1a2...-uuid", "name": "Financial Leadership Program"}, "trainer": {"id": "trainer-uuid", "name": "Dr. Farooq Al-Bateel"}, "mode": "online", "session_date": "2026-11-02", "start_time": "10:00", "duration_minutes": 120, "location": null, "trainer_joined_at": "2026-11-02 10:01:00", "trainer_attended": true, "attended_count": 18 }]
```

### `PUT /admin/programs/{program}/sessions/{session}` — Update a session

**Path variables:** `program`, `session`. Same fields as create, all `sometimes`.

### `DELETE /admin/programs/{program}/sessions/{session}` — Delete a session

No cascading recompute of anything — unlike Course's live sessions, deleting a program session does **not** touch `start_date` (it's a plain writable field here, not derived).

---

## Certificates

### `GET /admin/programs/{program}/certificates` — Every certificate issued for this program

**Path variables:** `program`. No query parameters.

**200 OK** (`CertificateResource[]`): `[{ "id": "cert-uuid", "student": {"id": "u-uuid", "name": "Sara Ahmed"}, "certificate_number": "CERT-2026-A1B2C3D4", "qr_token": "…48 chars…", "issued_at": "2026-12-01 09:00:00", "status": "valid" }]`

### `POST /admin/programs/{program}/certificates` — Issue one manually

**Path variables:** `program`. **Body:** `{ "user_id": "{{student_user_id}}" }` — `required, string, uuid, exists:users,uuid`.

Idempotent — re-issuing for a student who already has one returns that **existing** certificate (still `201`, not `200`).

**422 examples (plain `fail`, no `error_code`):**
```json
{ "status": "fail", "message": "This program does not offer a certificate.", "data": null }
{ "status": "fail", "message": "This student is not enrolled in the program.", "data": null }
```
**201 Created** → `CertificateResource`.
