# Online Sessions — Architecture & Usage Guide

The single reference for the provider-agnostic **online-session** platform: how it's
structured, how to create Zoom webinars/meetings (single, bulk, and shared-recurring),
how registration works, and how attendance is tracked.

> Scope note: this backend **no longer has** Zoom deep-analytics, KPI reporting, the
> Zoom webhook, or the reconciliation cron. Attendance is captured via join-click and
> admin actions only (see [Attendance](#attendance)). Anything you find referencing
> `ZoomTrackingService`, `hdb_zoom_analytics`, `reconcile`, `sync`, or webhooks is stale.

---

## 1. Architecture

A loosely-coupled design that turns Zoom into one interchangeable provider behind a
neutral online-session API, using **Strategy**, **Registry**, **Adapter**, and
**Facade** patterns.

**Goals**
- One provider-agnostic **online-session** module that picks a provider (Zoom today).
- Within a provider, session **types** (webinar / meeting) are independent, modular files.
- **Attendance** is a separate, provider-agnostic module.
- Adding a new provider or session type touches **zero** existing files.

### Module layout

```text
online-session/                          # provider-AGNOSTIC orchestrator
  controllers/online-session.controller.ts   # neutral REST API
  services/online-session.service.ts          # FACADE -> resolves provider, delegates
  services/online-session-provider.registry.ts# REGISTRY (self-registration idiom)
  interfaces/online-session-provider.interface.ts # the PORT every provider implements

zoom/                                     # the Zoom provider (flat top-level module)
  zoom.provider.ts                            # ADAPTER: implements the port, self-registers,
                                              #   one-line dispatch -> webinar or meeting handler
  sessions/zoom-session.handler.ts            # shared handler interface + shared orchestration base
  sessions/webinar.service.ts                 # webinar-specific logic
  sessions/meeting.service.ts                 # meeting-specific logic
  services/zoom-api.service.ts                # shared plumbing (REST)
  services/zoom-oauth.service.ts  zoom-jwt.service.ts
  services/zoom-registration.service.ts       # registration extension + contact/dispatch
  services/zoom-bulk-registration.service.ts  # background bulk-registration job
  controllers/ repositories/ dto/ enums/

online-attendance/                        # separate, provider-agnostic attendance module
  online-attendance.controller.ts / .service.ts / .repository.ts
```

### Service layers (parent → provider → per-type → common)

```text
        [ PARENT ]   online-session.service   (knows no Zoom)
                       │ resolve(provider) via registry
                       ▼
        [ PROVIDER ]  zoom.provider   (ADAPTER, self-registers in onModuleInit)
                       │ ternary on onlineType
              ┌────────┴────────┐
              ▼                 ▼
   [ PER-TYPE ] webinar.service   meeting.service   (only the differences)
              └────────┬────────┘
                       ▼
       [ COMMON ]  zoom-api.service ── zoom-oauth.service / zoom-jwt.service
                       │ REST
                       ▼
                     Zoom Cloud
```

Each tier only knows the tier directly below it. Webinar vs meeting is **not** a
registry — `ZoomProvider` picks the handler with a one-line ternary on `onlineType`;
a third type (e.g. live-stream) is one more handler file + one more branch.

### Pattern mapping

| Pattern | Where | Why |
| --- | --- | --- |
| **Strategy** | `OnlineSessionProvider` (per provider) | Each provider is independent; new provider = new class |
| **Registry** | `OnlineSessionProviderRegistry` | Pick a provider by key without a `switch` |
| **Adapter** | `zoom.provider.ts` | Translates Zoom REST into the neutral contract |
| **Facade** | `OnlineSessionService` | Controllers never know which provider runs |

### The provider port (current)

```ts
// online-session/interfaces/online-session-provider.interface.ts
export interface OnlineSessionProvider {
  readonly key: SessionProviderType;                       // 'zoom'
  create(input: CreateSessionInput): Promise<ProgramSession>;
  createShared(input: CreateSharedSessionInput): Promise<ProgramSession[]>;
  update(session: ProgramSession, input: UpdateSessionInput): Promise<ProgramSession>;
  remove(session: ProgramSession, actorUserId?: number): Promise<void>;
  // Registration (admin ops) — neutral DTOs in, neutral results out
  register(input: RegisterParticipantDto): Promise<ProgramRegistrationOnlineSession | void>;
  bulkRegister(input: BulkRegisterParticipantsDto, actorUserId?: number): Promise<BulkRegistrationStart>;
  bulkRegisterStatus(jobId: number): Promise<BackgroundJob>;
  bulkRegisterFailures(jobId: number, paging): Promise<BulkRegistrationFailureList>;
  retryBulkRegistration(jobId: number, actorUserId?: number): Promise<BulkRegistrationStart>;
  listRegistrations(sessionId: number, query): Promise<SessionRegistrationList>;
  exportRegistrations(sessionId: number, search?: string): Promise<{ fileUrl: string }>;
}
```

**Provider selection is a DTO field, persisted.** On create, the caller may send
`provider` (defaults to `zoom`); it's stored inside the session's `webinar_details` /
`meeting_details` jsonb and re-resolved on every later operation via
`OnlineSessionService.providerOf(session)` (defaulting to `zoom` for legacy rows).

---

## 2. Creating sessions (webinar & meeting)

> A `program_session` must **already exist**. Creating a webinar/meeting just attaches
> a Zoom resource to that session — it never creates the session itself. All endpoints
> are admin-only. Everything below works for **both** types — send
> `"onlineType": "webinar"` or `"onlineType": "meeting"` (webinar is the default).

**Link styles**
- **Webinar** — always per-user links (each seeker registers on Zoom); supports **panelists**; `requireRegistration` is ignored.
- **Meeting** — `requireRegistration: true` (default) → per-user link; `false` → one shared link + passcode for everyone. No panelists.

### Case 1 — Single (one session, one resource) · `POST /online-session`

```jsonc
{
  "onlineType": "webinar",           // or "meeting"; webinar is the default
  "programSessionId": 101,           // existing session to attach to
  "title": "Satsang",                // optional; defaults to the session name
  "startAt": "2026-08-03T10:00:00Z", // ISO date-time
  "duration": 90,                    // minutes
  "requireRegistration": true,       // meetings only (per-user vs shared link)
  "password": "optional",
  "launchMode": "sdk",               // "sdk" (in-app) or "zoomClient"
  "status": "draft",                 // draft | internalTesting | published
  "joinOpensMinutesBefore": 15,      // when the join link opens (default 15)
  "hostStartOpensMinutesBefore": 30  // when the host can start (display only)
}
```
One Zoom resource, one online-session row. Not shared with any other session.

### Case 2 — Bulk (many sessions, each gets its OWN resource) · `POST /online-session/bulk`

```jsonc
{
  "sessions": [
    { "onlineType": "webinar", "programSessionId": 101, "startAt": "2026-08-03T10:00:00Z", "duration": 90 },
    { "onlineType": "webinar", "programSessionId": 102, "startAt": "2026-08-04T10:00:00Z", "duration": 90 }
  ]
}
```
Each item is a Case 1 body, created **independently** — **not** all-or-nothing. The
response reports both sides:

```jsonc
{ "created": [ /* succeeded */ ], "failed": [ { "programSessionId": 102, "error": "…" } ] }
```
Result: N separate resources, N separate links (a seeker attending 3 sessions registers 3×).

### Case 3 — Shared / recurring (many sessions, ONE resource, ONE link) · `POST /online-session/shared`

Use when several sessions are really the same webinar/meeting on different days (e.g. a
12-day program). One recurring Zoom resource covers all sessions; each seeker registers
**once** and gets **one link valid for every session**.

```jsonc
// simplest valid payload:
{ "onlineType": "webinar", "programSessionIds": [101,102,103,104], "duration": 90 }
// meetings add: "requireRegistration": true | false
```

**What happens:** one recurring resource is created → each occurrence is moved to the
**exact date/time of its session** → one online-session row per session, all sharing
the same `external_id`/`join_url`, each tagged with its `occurrence_id`.

**Validations:** ≥1 session (`OS_BR_002`), all in one program (`OS_BR_003`), none
already provisioned, anchor session has a start time (`OS_BR_004`).

**`isRecurring` flag:** 2+ sessions default to **recurring**; a single session defaults
to **non-recurring**. Override with `isRecurring: true|false` — this is what lets you use
this endpoint for a single session, or for a group that is one continuous event (shared
link, no per-day occurrences).

**How dates are decided:** Zoom only accepts a *pattern* (daily/weekly/monthly), not an
arbitrary date list. So we (1) seed a weekly rule with the occurrence count **forced to
the number of sessions**, then (2) sort sessions + generated occurrences by time, pair
1:1, and PATCH each occurrence to its session's exact instant. **Omit `recurrence`** —
if sent, only `frequency`/`weeklyDays`/`repeatInterval` seed the pattern and are then
overwritten by the pinning step; there is no `endTimes`/`endDateTime` (count is always
the session count). Zoom weekday numbering: `1=Sun … 7=Sat`.

### Quick comparison

| | Case 1 — Single | Case 2 — Bulk | Case 3 — Shared/Recurring |
| --- | --- | --- | --- |
| Endpoint | `POST /online-session` | `POST /online-session/bulk` | `POST /online-session/shared` |
| Sessions per request | 1 | many | 1+ (recurring by default for 2+) |
| Resources created | 1 | 1 per session | **1 total** |
| Link per seeker | 1 (that session) | 1 per session (different) | **1 for all sessions** |
| Register a seeker | once per session | once per session | **once for all sessions** |
| Fails independently? | — | yes (`created` + `failed`) | no (all-or-nothing) |

---

## 3. Shared-recurring model — technical detail

**Design invariant — same row shapes in both modes.** Whether `PER_SESSION` (different
links) or `SHARED` (same link):
- One `hdb_online_session` row **per** `program_session` (never a single shared row).
- One `hdb_program_registration_online_session` (extension) row **per** `(registration, session)`.
- Attendance keyed by `(registration_id, session_id)`.

The **only** difference is the *values*: in `SHARED` every sibling row carries the same
`external_id`/`join_url`; in `PER_SESSION` each row has its own. So the read path (join
links, join window), attendance, and reporting work identically for both.

**Data model** — `hdb_online_session` has two nullable columns (migration
`database/migrations/2026-07-02-01-online-session-shared-webinar.sql`, additive; existing
rows read as `perSession`):

| Column | Type | Meaning |
| --- | --- | --- |
| `link_mode` | varchar(30) | `perSession` (default/NULL) or `shared` (`SessionLinkModeEnum`) |
| `occurrence_id` | varchar(255) | The Zoom occurrence this session maps to (NULL for `perSession`) |

Siblings of one shared resource are found by their common `external_id` (already indexed).

**Provisioning** (`ZoomSessionBase.createSharedGroup`): validate → build a weekly
recurrence (count forced to session count) → create one recurring resource → pin each
occurrence to its session's exact instant → write one row per session (same
`external_id`/`join_url`, `link_mode = shared`, each stamped with `occurrence_id`) → save
in one transaction, rolling back the Zoom resource on failure.

**Registration reuse:** for a `SHARED` session, before calling Zoom we look up any sibling
extension the registrant already holds under the same `external_id` and **reuse** its
`joinUrl`/`externalRegistrantId`/`isPanelist` — the seeker is pushed to Zoom once, and the
same link is written into one extension row per session.

**Cancellation:** Zoom registration is resource-level, so unregister removes the seeker
from **all** occurrences once and soft-deletes all their sibling extension rows. Deleting
a shared **session** deletes the Zoom resource only when the **last** session referencing
that `external_id` is removed.

---

## 4. Registration

**Single** · `POST /online-session/registrations` — register / unregister / downgrade one
program registrant for a session (`RegisterParticipantDto`).

**Bulk (background job)** · registers all eligible registrants of a program (by
`programId`, optionally scoped to a `sessionId`) as a background job on the shared
`background_jobs` table. The HTTP call returns a `jobId` immediately; work runs in the
background in paced batches (idempotent — already-registered pairs are skipped).

| Action | Endpoint | Notes |
| --- | --- | --- |
| Start bulk | `POST /online-session/registrations/bulk` | Returns `{ jobId, status, total }` |
| Poll status | `GET /online-session/registrations/bulk/:jobId` | Running counts + eligibility breakdown (no failure list) |
| Fetch failures | `GET /online-session/registrations/bulk/:jobId/failures?page=&limit=` | Paginated, **enriched** per-item failures (registrant + session details) |
| Retry failures | `POST /online-session/registrations/bulk/:jobId/retry` | Re-runs only the failed pairs as a fresh job; returns a new `jobId` |

The failure list is served by its own endpoint (not the status poll) because it can be
large and is only needed once a job reports failures. Each failure row carries
`registrationSeqNumber`, `fullName`, `email`, `mobile`, `sessionName`, plus the stable
`reason` code and `message`, so an admin can act on it directly.

**Admin views**
- `GET /online-session/:id/registrations` — paginated/searchable "registration ↔ join URL" list (`download=true` for Excel).

---

## 5. Attendance

Provider-agnostic, owned by the **online-attendance** module. All attendance lives in
`program_user_attendance.attendance_events` (append-only). Sources: **join-click**,
**manual admin**, and **QR scan** (the last owned by the `qr-attendance` module). There
is no Zoom-webhook / reconciliation source anymore.

| Action | Endpoint | Notes |
| --- | --- | --- |
| Join (mark JOIN_CLICK + get link) | `POST /online-attendance/sessions/:sessionId/join` | Enforces the join window; supports companion crediting |
| Admin mark attended | `POST /online-attendance/sessions/:sessionId/attendance/mark` | By registration id (proxy/child-safe); idempotent |
| Admin unmark | `POST /online-attendance/sessions/:sessionId/attendance/unmark` | Resets summary flags; event log kept for audit |
| Session report | `GET /online-attendance/sessions/:sessionId/report` | `download=true` for Excel |
| Program report | `GET /online-attendance/programs/:programId/report` | `download=true` for Excel |

The join flow captures client metadata (mode of joining, device) on
`program_user_attendance` and enforces an admin-configured join window (`opensAt` =
`joinOpensMinutesBefore` before start, default 15; closes at actual/expected end).

---

## 6. Zoom-branded endpoints that remain

Only genuinely Zoom-specific endpoints stay under `/zoom`:
- `POST /zoom/token` — join-token generation.
- `/zoom/webinar-templates` — webinar template management.

Everything else (session CRUD, registration, attendance) is on the neutral
`/online-session` and `/online-attendance` APIs.

---

## 7. Caveats

- **Zoom occurrence semantics** for shared-recurring resources are implemented from the
  API spec; smoke-test the exact-date `PATCH` and per-occurrence behaviour against a live
  recurring resource before production.
- Zoom caps a recurring resource at **50 occurrences**.
- Derived weekly recurrence uses **server-timezone** weekdays; pass an explicit
  `recurrence` seed for cross-timezone control (dates are still pinned to session instants).
- The provider-abstraction layer is real overhead for a single provider (YAGNI tension);
  it pays off when provider #2 appears.
