# Online Session — Feature 2: Register Participants (Single & Bulk)

> Push program registrants onto the Zoom resource backing a session, list/export
> their join URLs, and unregister/downgrade them. The canonical "who registered"
> record stays in `hdb_program_registration`; this feature writes a per-registrant
> **extension row** in `hdb_program_registration_online_session` holding the Zoom
> `external_registrant_id`, `join_url`, and role.

The HTTP layer is provider-neutral (`OnlineSessionController` →
`OnlineSessionService` facade → `ZoomProvider` → `ZoomRegistrationService` /
`ZoomBulkRegistrationService`).

---

## 1. Endpoints

| Method / Path | Handler | Purpose | Success |
|---|---|---|---|
| `POST /online-session/registrations` | `register()` [:141](../../src/online-session/controllers/online-session.controller.ts#L141) | Register / unregister / downgrade **one** registrant | `200` |
| `POST /online-session/registrations/bulk` | `bulkRegister()` [:164](../../src/online-session/controllers/online-session.controller.ts#L164) | Bulk-register a program/session's registrants (async job) | `202` |
| `GET /online-session/registrations/bulk/:jobId` | `bulkRegisterStatus()` [:186](../../src/online-session/controllers/online-session.controller.ts#L186) | Poll bulk job progress | `200` |
| `GET /online-session/:id/registrations` | `listRegistrations()` [:203](../../src/online-session/controllers/online-session.controller.ts#L203) | List registrants + join URLs (paginated/search; `download=true` → Excel) | `200` |

All require `admin` (`CombinedAuthGuard` + `RolesGuard`). Send `Content-Type: application/json`.

---

## 2. Single registration — `POST /online-session/registrations`

### Body — `RegisterParticipantDto`

| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
| `registrationId` | `int` | ✅ | — | `hdb_program_registration.id` |
| `action` | enum | ✅ | `register` | `register` / `unregister` / `downgradeToAudio` |
| `sessionId` | `int` | — | registration's own session | Required when a registration spans multiple online sessions |
| `role` | enum | — | `attendee` | `attendee` / `panelist` (only for `register`) |
| `actingUserId` | `int` | — | from auth | Set from `req.user.id` |

### Dispatch — `ZoomRegistrationService.handle()` [:113](../../src/zoom/services/zoom-registration.service.ts#L113)

```
handle(dto)
  ├─ register   → register()   → guards no existing extension → pushParticipant()
  ├─ unregister → unregister()  → remove from Zoom + soft-delete extension row
  └─ downgrade  → downgrade()   → webinar-only: panelist → audio attendee
```

- **`pushParticipant()`** [:191](../../src/zoom/services/zoom-registration.service.ts#L191) — picks the handler by type (`handlerFor`: meeting vs webinar), calls `addParticipant()`, then writes the extension row with `externalRegistrantId`, `joinUrl`, `isPanelist`, and `registrationType` (`video` for panelist, `audio` for attendee).
- **`requireOnlineSession()`** [:228](../../src/zoom/services/zoom-registration.service.ts#L228) — fails with `ZOOM_SESSION_NOT_PROVISIONED` if the session has no Zoom `externalId` (avoids writing an empty row that would falsely count as "registered").
- Re-registering the same person → `ZOOM_USER_ALREADY_REGISTERED`.
- **Downgrade** is webinar-only; non-panelist → `ZOOM_NOT_A_PANELIST`.

---

## 3. Bulk registration — `POST /online-session/registrations/bulk`

### Body — `BulkRegisterParticipantsDto`

| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
| `programId` | `int` | one of programId/sessionId | — | Registers the program's confirmed registrants |
| `sessionId` | `int` | one of programId/sessionId | — | Registers that session's registrants |
| `role` | enum | — | `attendee` | `attendee` / `panelist` for everyone |
| `batchSize` | `int` (1–50) | — | `10` | Registrants processed per batch |

### Flow — `ZoomBulkRegistrationService.startBulkRegistration()` [:42](../../src/zoom/services/zoom-bulk-registration.service.ts#L42)

```
1. resolveTargetSession(dto)           — ONE Zoom-provisioned session:
     • sessionId given → that session (must have externalId)
     • else program's single provisioned session
       (0 or >1 → ZOOM_BULK_WEBINAR_UNRESOLVED, must pass sessionId)
2. findEligibleRegistrationsByProgram() — registrants are PROGRAM-scoped
3. createBulkJob(...)                   — background_jobs row, status PROCESSING
4. return { jobId, status, total }      — 202 immediately
5. setImmediate → runBulkRegistration() — batches of batchSize, Promise.all per batch
     each → registerForBulk() → pushParticipant()  (SAME path as single register)
     • already registered → skipped (idempotent re-run)
     • Zoom/DB error      → failed (tallied, doesn't abort the job)
6. updateBulkJob progress per batch; final status COMPLETED / FAILED
```

> Note: registrants carry no session, so they're fetched by **program**. A
> `sessionId` only selects the *target* webinar/meeting — everyone eligible in the
> owning program is registered against it.

### Poll status — `GET /online-session/registrations/bulk/:jobId`

Returns the `background_jobs` row: `status`, `total`, `generated` (registered),
`skipped`, `failed`. `ZOOM_BULK_JOB_NOTFOUND` if the id is unknown.

---

## 4. Will the join links be the same? (per-registrant vs shared)

Bulk uses the **same** `addParticipant()` path as single registration, so link
behaviour is identical and depends only on **session type**:

| Session type | Link per registrant | Source |
|---|---|---|
| Webinar — attendee | ✅ Unique | `addRegistrant()` returns per-user `join_url` |
| Webinar — panelist | ✅ Unique | `addPanelist()` + `fetchPanelists()` lookup |
| Meeting — `requireRegistration: true` (default) | ✅ Unique | `addMeetingRegistrant()` per-user `join_url` |
| Meeting — `requireRegistration: false` (shared link) | ❌ Same for all | hands out `onlineSession.joinUrl` ([meeting.service.ts:159](../../src/zoom/sessions/meeting.service.ts#L159)) |

So bulk behaves exactly like webinar single-registration — every registrant gets
a unique join URL. The only same-for-everyone case is a shared-link meeting,
which has no per-user registrant concept.

---

## 5. List / export registrations — `GET /online-session/:id/registrations`

Query — `ListSessionRegistrationsDto`: `page` (1), `limit` (20), `search`
(name/email/mobile), `download` (`true` → Excel).

- JSON: `{ data: RegistrationWithJoinUrl[], pagination: { page, limit, total } }`
  — each row carries its `joinUrl` (null until pushed to Zoom).
- Excel ([zoom-registration.service.ts:67](../../src/zoom/services/zoom-registration.service.ts#L67)) columns: S.No., Seq Number, Full Name, Email, Mobile, Registration Status, Seat Allocated, **Registered to Zoom** (Yes/No), **Role** (Panelist/Attendee), **Join URL**. Returns `{ fileUrl }` (uploaded to S3).

---

## 6. What gets persisted

`hdb_program_registration_online_session` — one active row per
`(registration_id, online_session_id)` ([entity](../../src/common/entities/program-registration-online-session.entity.ts)):

| Field | Meaning |
|---|---|
| `external_registrant_id` | Zoom registrant/panelist id (used for unregister/downgrade) |
| `join_url` | Per-registrant join URL (or shared link for shared-link meetings) |
| `is_panelist` | Panelist vs attendee |
| `registration_type` | `video` (panelist) / `audio` (attendee) |
| `provider` | `zoom` |

Unregister soft-deletes the row; downgrade flips `is_panelist`→false,
`registration_type`→`audio`, and swaps the Zoom id + join URL.

---

## 7. Errors

| Condition | Code | HTTP |
|---|---|---|
| Registration not found | `ZOOM_REGISTRATION_NOTFOUND` | 404 |
| Session not Zoom-provisioned | `ZOOM_SESSION_NOT_PROVISIONED` | 400 |
| Already registered (single) | `ZOOM_USER_ALREADY_REGISTERED` | 400 |
| Downgrade a non-panelist | `ZOOM_NOT_A_PANELIST` | 400 |
| Bulk target ambiguous (0 or >1 provisioned) | `ZOOM_BULK_WEBINAR_UNRESOLVED` | 400 |
| Bulk job id unknown | `ZOOM_BULK_JOB_NOTFOUND` | 404 |
| Bulk start failed | `ZOOM_BULK_START_FAILED` | mapped |
| Invalid action | `ZOOM_INVALID_WEBINAR_STATE` | 400 |
