# Zoom / Online Sessions & Attendance — Complete Reference

The single source of truth for online sessions (Zoom webinars & meetings),
registration, join, attendance, reporting, and analytics.

- **Base URL (dev):** `http://localhost:9001` (from `PORT`; no global route prefix)
- **Response envelope:** every endpoint returns `{ success, data, error }`
  (the webhook URL-validation handshake is the only exception — it echoes Zoom's token).
- **Prerequisite:** `ENABLE_ZOOM=true` + Zoom env vars (see `.env.zoom.example`). With it
  off, outbound Zoom calls are skipped (`Z_DISABLED`).

---

## 1. What an admin can do (capability map)

| Capability | Endpoint(s) |
|---|---|
| Create a meeting/webinar — **single** | `POST /online-session` |
| Create — **bulk** | `POST /online-session/bulk` |
| List / view / update / delete | `GET /online-session`, `GET/PUT/DELETE /online-session/:id` |
| Move status (draft → internalTesting → published) | `PATCH /online-session/:id/status` |
| Choose **launch mode** (in-app SDK vs Zoom client) | `launchMode` on create/update |
| Choose **join window** (link opens N min before) | `joinOpensMinutesBefore` on create/update |
| Register one registrant | `POST /zoom/webinar/register` |
| **Bulk-register** all eligible registrants (background job) | `POST /zoom/webinar/bulk-register` + `GET …/status/:jobId` |
| **View registration ↔ join URL** (copy links) + Excel | `GET /zoom/webinar/sessions/:sessionId/registrations` (`?download=true`) |
| Participant join (marks attendance) | `POST /online-attendance/sessions/:sessionId/join` |
| Manual mark / bulk mark / **unmark** attendance | `POST /qr-attendance/{manual-checkin,bulk-manual-checkin,undo-checkin}` |
| Attendance reports + Excel | `GET /online-attendance/{sessions/:id,programs/:id}/report` (`?download=true`) |
| Pull attendance & analytics from Zoom (admin button) | `POST /zoom/webinar/:id/sync`, `GET /zoom/webinar/:id/analytics` |
| SDK join token (in-app) | `POST /zoom/token/join` |

All management endpoints require the `admin` role; participant **join** needs any logged-in
user; **webhooks** are unauthenticated (verified by Zoom HMAC).

**Auth headers:** `Authorization: Bearer <firebase-id-token>`, `userid: <id>`,
`active-role: <role>` (e.g. `admin`).

---

## 2. Architecture (provider-agnostic)

```
HTTP → OnlineSessionController
       → OnlineSessionService (FACADE: DTO → neutral CreateSessionInput/UpdateSessionInput)
         → OnlineSessionProviderRegistry (key → provider; self-registered)
           → ZoomSessionProvider (ADAPTER) → Webinar/MeetingService (extend ZoomSessionBase)
             → ZoomApiService (typed Zoom REST client) → Zoom
```

The orchestrator (`src/online-session/`) **never imports Zoom**. Zoom plugs in via
`ZoomSessionProvider`, which self-registers with the registry — adding a provider (Teams, …)
touches zero existing files. The same pattern governs attendance: `OnlineAttendanceService`
knows nothing of Zoom's signatures/payloads — that lives in `ZoomAttendanceAdapter` behind the
`AttendanceWebhookAdapter` port.

**Key files**

| Layer | File |
|---|---|
| Session controller / facade / registry | `src/online-session/{controllers,services}/…` |
| Zoom adapter + handlers | `src/zoom/zoom.provider.ts`, `src/zoom/sessions/{webinar,meeting}.service.ts`, `zoom-session.handler.ts` |
| Zoom REST client | `src/zoom/services/zoom-api.service.ts` |
| Registration (single + bulk + view) | `src/zoom/services/{zoom-registration,zoom-bulk-registration}.service.ts`, `repositories/zoom-registration.repository.ts` |
| Attendance | `src/online-attendance/online-attendance.service.ts`, `webhook/zoom-attendance.adapter.ts` |
| Manual/QR check-in | `src/qr-attendance/` |
| Reconciliation cron | `src/online-analytics/online-analytics.cron.ts` |

---

## 3. Data model

**`hdb_online_session`** — canonical online-session record (1:1 with a program session).
Notable columns: `type` (meeting/webinar/live_stream), `provider`, `external_id` (Zoom id),
`join_url`, `password`, `registration_url`, `panelist_url`, `start_url` (host-only, never
exposed), `status`, `require_registration`, `actual_meeting_ends_at`, and:
- **`launch_mode`** — `sdk` | `zoomClient` (null → `sdk`).
- **`join_opens_minutes_before`** — minutes before start the join link opens (null → 15).

**`hdb_program_registration_online_session`** — 1:1 per program registration; holds the
per-user `external_registrant_id`, `join_url`, `is_panelist`, `registration_type`.

**`program_user_attendance`** — attendance with an append-only `attendance_events` JSONB log;
summary flags `is_attended`, `is_manually_checked_in`, `checked_in_at`, `checked_in_by_user_id`.

**`zoom_analytics`** — per-user post-session analytics (join/leave, duration, late/dropoff,
absentee, …) produced by reconciliation.

**`background_jobs`** — shared job table reused by the bulk-register job (`type =
bulk_zoom_registration`), tracking `total / generated / skipped / failed / status`.

---

## 4. Status lifecycle

`ZoomWebinarStatus`: **`draft` → `internalTesting` → `published` → `completed`**.

- New sessions default to `draft`, but an initial `status` can be passed on create
  (e.g. `internalTesting`).
- Webhooks auto-advance: `*.started` → `published`, `*.ended` → `completed`.
- Admin can override manually: `PATCH /online-session/:id/status` (local-only; never calls Zoom).

---

## 5. Create & manage sessions (admin)

### Create — `POST /online-session`
```jsonc
{
  "programSessionId": 123,          // required
  "onlineType": "webinar",          // "webinar" | "meeting" (default webinar)
  "startAt": "2026-07-01T10:00:00Z",// required, ISO 8601
  "duration": 60,                   // required, minutes
  "title": "Monthly Satsang",
  "password": "optional",
  "hostEmail": "optional@host.com", // defaults to ZOOM_ADMIN_EMAIL
  "requireRegistration": true,      // meetings only (per-user links vs one shared link)
  "launchMode": "zoomClient",       // "sdk" (default) | "zoomClient"
  "joinOpensMinutesBefore": 30,     // default 15
  "status": "internalTesting"       // draft (default) | internalTesting | published | completed
}
```
Response `data` is the session view with host `startUrl` stripped. On a DB failure after the
Zoom resource is created, it is rolled back (deleted) so nothing is orphaned.

> The registration window (`registrationStartsAt` / `registrationEndsAt`) is **not** set here —
> it belongs to the **program session**, a separate concern from provisioning the online session.
> The online session's own time gate is `joinOpensMinutesBefore` (see §7).

**Meeting variants:** `requireRegistration:true` → per-user registrant links;
`false` → one shared join link + passcode.

### Bulk create — `POST /online-session/bulk`
Body `{ "sessions": [ <CreateOnlineSessionDto>, … ] }`. Each session is provisioned
independently (external API calls can't share a DB transaction); response returns
`{ created, failed }` side by side so only failures are retried.

### Others
- **List** — `GET /online-session?limit=&offset=&status=&programId=`
- **Get** — `GET /online-session/:id`
- **Update** — `PUT /online-session/:id` (all fields optional; only sent fields change)
- **Status** — `PATCH /online-session/:id/status` `{ "status": "published" }`
- **Delete** — `DELETE /online-session/:id` (removes the Zoom resource + clears details)

---

## 6. Registration

> **Registrants are fetched by program.** `hdb_program_registration` carries a `program_id`;
> `program_session_id` is typically empty, so the registrant set is resolved **by program**, not
> by session. A Zoom webinar lives on a program *session*. Bulk-register therefore resolves a
> single **target session** (the webinar) and registers all the program's eligible registrants to
> it. The 1:1 `hdb_program_registration_online_session` extension holds each registration's single
> join URL (so a registration maps to one webinar).

### Eligibility rule (who counts as registerable / shown in the view)
```
program_id = <the target session's program>
AND seatAllocated = true
AND deletedAt IS NULL
AND registrationStatus NOT IN (rejected, cancelled, save_as_draft, archived)
```
(`REGISTRATION_INELIGIBLE_STATUSES` in `zoom-registration.repository.ts`.)

### Single — `POST /zoom/webinar/register`
```jsonc
{ "registrationId": 456, "action": "REGISTER", "role": "Attendee" }
// action: REGISTER | UNREGISTER | DOWNGRADE_TO_AUDIO ; role: Attendee | Panelist
```
Stores the per-user join URL + registrant id on the registration's online-session extension.
`actingUserId` comes from auth.

### Bulk — background job
Register **all the program's eligible registrants** to a target webinar session:
```
POST /zoom/webinar/bulk-register
  { "sessionId": 1392 }                       // register the program's registrants to THIS session's webinar
  { "programId": 3 }                          // resolves the program's single provisioned webinar session
  { "sessionId": 1392, "role": "Panelist" }   // join role: "Attendee" (default) | "Panelist"
  { "sessionId": 1392, "batchSize": 20 }      // optional, default 10
→ 202 { "jobId", "status", "total" }
```
**Target-session resolution:** `sessionId` → that session (must be Zoom-provisioned). `programId`
→ the program's single session that has a Zoom `external_id`; zero or several provisioned webinars
is ambiguous → `Z_BR_005` ("specify a sessionId"). Either way the registrant set is the **whole
program's** eligible registrants (`program_session_id` is ignored).

Runs in the background (mirrors the QR bulk-job pattern: `setImmediate` + per-batch counter
updates on `background_jobs`). **Idempotent** — already-registered registrants count as
`skipped`; one registrant's failure increments `failed` and never aborts the batch. A registrant
whose target session has no Zoom resource fails fast with `Z_BR_004` (never a silent fake-success).

Poll progress:
```
GET /zoom/webinar/bulk-register/status/:jobId
→ { total, generated (registered), skipped, failed, status: processing|completed|failed, … }
```

### Registration ↔ join URL view (+ Excel) — `GET /zoom/webinar/sessions/:sessionId/registrations`
Resolves the session's **program** and lists that program's **eligible** registrants, each with its
Zoom **join URL** (to display/copy). `joinUrl` is `null` until that registrant has been pushed
to Zoom — so the view also shows who still needs registering.
- Query: `page` (1), `limit` (20), `search` (name/email/mobile), `download` (true → Excel).
- `?download=true` returns `{ fileUrl }` (S3 .xlsx) with columns incl. **Registered to Zoom**
  (Yes/No) and **Join URL**.

---

## 7. Join, time window & launch mode (participant)

### `POST /online-attendance/sessions/:sessionId/join`
Any authenticated **eligible** registrant. Marks a `JOIN_CLICK` attendance event and returns
the join details:
```jsonc
{ "data": {
  "joinUrl": "https://zoom.us/j/...",
  "onlineType": "webinar",
  "launchMode": "sdk",            // tells the frontend: in-app SDK vs Zoom client
  "meetingId": "87654321098",
  "meetingPassword": "abc123",
  "attendanceMarked": true
} }
```
**Join window** is enforced: the link opens `joinOpensMinutesBefore` (default 15) before
`startsAt` and closes at the actual end (or `startsAt + duration`):
- before the window → `400 JOIN_NOT_OPEN_YET`
- after it ends → `400 JOIN_WINDOW_CLOSED`

### SDK in-app join — `POST /zoom/token/join`
```jsonc
{ "meetingNumber": "87654321098", "role": 0 }   // 1 = host/panelist, 0 = attendee
```
Returns the signed JWT the Zoom Web/Meeting SDK uses to join client-side. (Use when
`launchMode = sdk`; for `zoomClient`, open the `joinUrl` directly.)

---

## 8. Attendance sources

`AttendanceSourceEnum`: `JOIN_CLICK`, `ZOOM_WEBHOOK`, `MANUAL_ADMIN`, `QR_SCAN`. All append to
the same `attendance_events` log (first event sets `isAttended`/`checkedInAt`).

| Source | How |
|---|---|
| `JOIN_CLICK` | participant join endpoint (above) |
| `ZOOM_WEBHOOK` | live `*.participant_joined` webhook + reconciliation (idempotent) |
| `MANUAL_ADMIN` | `POST /qr-attendance/manual-checkin`, `POST /qr-attendance/bulk-manual-checkin` |
| `QR_SCAN` | `POST /qr-attendance/scan` |
| **Unmark** | `POST /qr-attendance/undo-checkin` (reverts the attendance record) |

Manual/QR live in the **qr-attendance** module; they write to the same log, so report
breakdowns cover every source without duplication.

---

## 9. Reports & Excel export (admin)

### Session — `GET /online-attendance/sessions/:sessionId/report`
Query: `page`, `limit`, `isAttended`, `search`, **`download`** (true → Excel).
```jsonc
{ "data": {
  "sessionId": 123, "sessionName": "...",
  "totalRegistrants": 100, "totalAttended": 72, "attendanceRate": "72.0%",
  "breakdown": { "JOIN_CLICK": 40, "ZOOM_WEBHOOK": 70, "MANUAL_ADMIN": 2, "QR_SCAN": 0 },
  "records": [ … ], "pagination": { "page": 1, "limit": 20, "total": 100 }
} }
```
`?download=true` → `{ fileUrl }` (S3 .xlsx).

### Program — `GET /online-attendance/programs/:programId/report?sessionId=`
Per-session summaries + `overallAttendanceRate`. `sessionId` optional.

---

## 10. Analytics & reconciliation

- **Admin pull from Zoom** — `POST /zoom/webinar/:id/sync` fetches Zoom participants /
  registrants / absentees, rebuilds `zoom_analytics`, and records attendance (idempotent).
  `GET /zoom/webinar/:id/analytics` returns the computed rows. (`:id` = `program_session.id`.)
- **Automatic fallback** — `OnlineAnalyticsCron` re-syncs recently-ended sessions hourly
  (`ZOOM_RECONCILIATION_CRON`, default `0 * * * *`), so attendance/analytics are correct even
  if a webhook was missed. No manual action needed.

---

## 11. Webhooks (configured in Zoom, not called by clients)

Both are public; authenticity is the Zoom HMAC signature (`ZOOM_WEBHOOK_SECRET_TOKEN`). They
auto-handle Zoom's `endpoint.url_validation` challenge.

| Purpose | Endpoint |
|---|---|
| Attendance (`*.participant_joined`) | `POST /online-attendance/zoom/webhook` |
| Session lifecycle (`*.started` / `*.ended`) | `POST /zoom/webhook` |

---

## 12. Configuration

`ENABLE_ZOOM`, `ZOOM_ADMIN_EMAIL`, `ZOOM_SDK_KEY`, `ZOOM_SDK_SECRET`,
`ZOOM_WEBHOOK_SECRET_TOKEN`, OAuth (server-to-server) creds, `ZOOM_RECONCILIATION_CRON`.
See `src/zoom/zoom.constants.ts` (`ZOOM_ENV_KEYS`, `ZOOM_DEFAULTS`, `ZOOM_API_PATHS`).

---

## 13. Error codes (registry — `error-string-constants.ts` + `i18n/error-messages.ts`)

| Constant | Code | Raised when |
|---|---|---|
| `NOT_REGISTERED_FOR_SESSION` | `OA_BR_001` | join without an eligible registration |
| `SESSION_NOT_ONLINE` | `OA_BR_002` | join on a non-online session |
| `JOIN_LINK_NOT_CONFIGURED` | `OA_BR_003` | join link missing |
| `JOIN_NOT_OPEN_YET` | `OA_BR_004` | join before the window opens |
| `JOIN_WINDOW_CLOSED` | `OA_BR_005` | join after the session ends |
| `ZOOM_USER_ALREADY_REGISTERED` | `Z_BR_001` | single register when already registered |
| `ZOOM_SESSION_NOT_PROVISIONED` | `Z_BR_004` | target session has no Zoom meeting/webinar |
| `ZOOM_BULK_WEBINAR_UNRESOLVED` | `Z_BR_005` | by-program: 0 or >1 provisioned webinars → pass sessionId |
| `ZOOM_BULK_JOB_NOTFOUND` | `Z_NF_004` | polling an unknown bulk job |
| `ZOOM_BULK_START_FAILED` | `Z_BULK_START_FAILED` | bulk job failed to start |
| `ONLINE_SESSION_NOTFOUND` | `OS_NF_001` | session not found |
| `ONLINE_SESSION_OPERATION_FAILED` | `OS_OP_FAILED` | create/update/remove failure |
| `ZOOM_DISABLED` | `Z_DISABLED` | `ENABLE_ZOOM` off |

---

## 14. End-to-end flow

1. Admin `POST /online-session` (or `/bulk`) → Zoom webinar/meeting created (`draft`),
   with `launchMode` + `joinOpensMinutesBefore`.
2. Admin `POST /zoom/webinar/bulk-register {sessionId|programId}` → background job pushes all
   eligible registrants; poll `…/status/:jobId`.
3. Admin `GET /zoom/webinar/sessions/:id/registrations` → view/copy per-user join URLs
   (or `?download=true` for Excel).
4. Participant `POST /online-attendance/sessions/:id/join` → JOIN_CLICK + link (window-gated);
   in-app clients also `POST /zoom/token/join`.
5. Live: Zoom → `POST /online-attendance/zoom/webhook` (ZOOM_WEBHOOK); `*.started/ended`
   advance status.
6. After: admin `POST /zoom/webinar/:id/sync`, then `…/report` (+ Excel) and `…/analytics`.
   The hourly cron backfills anything missed.

---

## 15. Deferred / out of scope

- `internalUser` analytics flag population (awaiting user.type/org definition).
- INTERNAL_TESTING visibility-by-role rules.
- Webhook retry queue (the hourly reconciliation cron is the fallback).
- Auto-push to Zoom on registration-confirmed (bulk-register is the current path).

> **DB note:** the `ALTER TYPE public.job_type_enum ADD VALUE 'bulk_zoom_registration'`
> migration must be applied before the bulk-register job is used, and it cannot run inside a
> transaction block.
