# Eligible Registration Activation (Active ↔ Inactive per Online Session) — Implementation Plan

> **Status: PROPOSED — not yet implemented.** This is the design/implementation plan requested for review. No code changes have been made. Awaiting go-ahead.

## 1. Overview

Three things to build:

1. An API to list all **eligible** registrations for a program — "eligible" = holds an allocated seat (`ProgramRegistration.seatAllocated = true`).
2. A way for Admin / RM / Shoba to flip an eligible registration between **active** and **inactive** *for a specific online session*, tracked per-session (not a single flag on the registration), with the Zoom join link removed on deactivation and re-issued on reactivation.
3. A live **eligible-active count** maintained per online session (e.g. Session 1 → 500, Session 2 → 490, Session 3 → 493), reflecting exactly who is currently active for that session after toggling.

### Decisions confirmed with stakeholder before writing this plan

| Question | Decision |
|---|---|
| What does the per-session "eligible count" mean? | **Live/derived count** of currently-active eligible registrants for that session — not a capacity cap admin sets upfront. It's expected to drift session to session as people get toggled. |
| When can active↔inactive be toggled relative to the session? | **Allowed before and after** the session. **Blocked while the session is live** (in progress). |

---

## 2. Relevant existing code (what we're building on top of)

| Concern | File | Notes |
|---|---|---|
| Registration entity, seat flag | `src/common/entities/program-registration.entity.ts` | `seatAllocated: boolean` (default false) is the existing "holds a seat" flag. `registrationStatus` enum (`draft, pending, waitlisted, completed, cancelled, rejected, on_hold, archived, ...`) in `src/common/enum/registration-status.enum.ts`. |
| Seat allocation set/unset | `src/common/utils/seat-count.util.ts` | `incrementSeatCounts()` / `decrementSeatCounts()` — atomic conditional `UPDATE` on `Program.filledSeats` / `ProgramSession.reservedSeats`, and flips `ProgramRegistration.seatAllocated`. This is the pattern our new eligible-count counter will mirror. |
| Registration ↔ online-session join row | `src/common/entities/program-registration-online-session.entity.ts` | Table `hdb_program_registration_online_session`, class `ProgramRegistrationOnlineSession`. One row per `(registrationId, onlineSessionId)`, unique while not soft-deleted. Holds `externalRegistrantId`, `joinUrl`, `status` (`REGISTERED`/`FAILED` — **provisioning outcome, not attendance/activation** — must not repurpose this field). |
| Online session (Zoom meeting/webinar) | `src/common/entities/online-session.entity.ts` | Table `hdb_online_session`, 1:1 with `ProgramSession`. `status` here is `ZoomWebinarStatus` (`draft/internalTesting/published/completed`) — publishing lifecycle, **not** "is it happening right now". |
| Session start/end time | `src/common/entities/program-session.entity.ts` | `startsAt`, `endsAt` (Date) — this is what we'll use to detect "session is live" for the toggle-timing guard. |
| Zoom provider calls (low-level) | `src/zoom/sessions/meeting.service.ts`, `src/zoom/sessions/webinar.service.ts` | Both implement `ZoomSessionHandler`: `addParticipant(session, contact, role)` → calls `zoomApi.addMeetingRegistrant` / `addRegistrant`/`addPanelist`, returns `{ joinUrl, zoomRegistrantId, isPanelist }`. `removeParticipant(session, extension)` → calls `zoomApi.removeMeetingRegistrant` / `removeUser`. **Neither of these methods touches the DB row** — they're pure provider calls. |
| Zoom orchestration (high-level, NOT reusable as-is) | `src/zoom/services/zoom-registration.service.ts` | `handle(dto)` dispatches REGISTER/UNREGISTER/DOWNGRADE. `register()` throws `ZOOM_USER_ALREADY_REGISTERED` if a (non-deleted) extension row already exists — this will collide with our design, see §5.3. `unregister()` calls `handlerFor(session).removeParticipant()` then **soft-deletes** the extension row via `registrationRepository.softDeleteZoomExtension()` — also collides, see §5.3. **We will call the low-level `addParticipant`/`removeParticipant` directly, not go through `handle()`.** |
| Roles / permissions | `src/common/constants/strings-constants.ts` (`ROLE_VALUES`), `src/common/decorators/roles.decorator.ts`, `src/common/guards/roles.guard.ts` | `ROLE_VALUES.ADMIN = 'admin'`, `ROLE_VALUES.RM = ROLE_VALUES.RELATIONAL_MANAGER = 'relational_manager'`, `ROLE_VALUES.COORDINATOR = 'shoba'`. Pattern used throughout `registration.controller.ts`: `@UseGuards(CombinedAuthGuard, RolesGuard)` + `@Roles(ROLE_VALUES.RELATIONAL_MANAGER, ROLE_VALUES.COORDINATOR, ROLE_VALUES.ADMIN)`. |

---

## 3. Data model changes

### 3.1 `hdb_program_registration_online_session` — add activation tracking

New enum `src/common/enum/registration-online-session-activation-status.enum.ts`:

```ts
export enum RegistrationOnlineSessionActivationStatus {
  ACTIVE = 'active',
  INACTIVE = 'inactive',
}
```

New columns on `ProgramRegistrationOnlineSession`:

| Column | Type | Default | Purpose |
|---|---|---|---|
| `activation_status` | varchar(20) | `active` | Current state for this registration+session pair. This is what answers "at which session marked active/inactive" — it's already keyed by `(registrationId, onlineSessionId)`. |
| `activation_changed_at` | timestamptz, nullable | null | When it was last toggled. |
| `activation_changed_by` | int, nullable (FK → user) | null | Who toggled it (admin/RM/Shoba). |
| `activation_reason` | text, nullable | null | Optional note captured from the RM/Shoba when deactivating/reactivating. |

Kept separate from the existing `status` (`REGISTERED`/`FAILED`) column deliberately — that column's semantics ("did provisioning succeed") are already relied on elsewhere (generated-counts, skip-checks in bulk registration). Conflating it with activation would be a correctness risk.

### 3.2 `hdb_online_session` — add live eligible-active counter

| Column | Type | Default | Purpose |
|---|---|---|---|
| `eligible_active_count` | int | 0 | Live count of rows in `hdb_program_registration_online_session` for this session with `activation_status = 'active'` (and `status = 'registered'`, i.e. actually provisioned). Maintained via atomic increment/decrement on every toggle, mirroring `incrementSeatCounts`/`decrementSeatCounts`. |

Backfill migration: after adding the column, one-time `UPDATE hdb_online_session SET eligible_active_count = (SELECT COUNT(*) FROM hdb_program_registration_online_session WHERE online_session_id = hdb_online_session.id AND status = 'registered' AND deleted_at IS NULL)`.

### 3.3 Migration files

Follow existing naming convention under `database/zoom/` (date-prefixed, e.g. `2026-06-25-01-registration-online-session-per-session.sql`):
- `<date>-01-registration-online-session-activation-status.sql`
- `<date>-02-online-session-eligible-active-count.sql`

No changes needed to `hdb_program_registration` — `seatAllocated` already exists and is exactly the "allocated a seat" flag requirement #1 needs.

---

## 4. New APIs

All three endpoints: `@UseGuards(CombinedAuthGuard, RolesGuard)` + `@Roles(ROLE_VALUES.ADMIN, ROLE_VALUES.RM, ROLE_VALUES.COORDINATOR)` — i.e. `admin`, `relational_manager`, `shoba` — matching the existing pattern in `registration.controller.ts`.

### 4.1 `GET /registration/eligible` — list eligible (seat-allocated) registrations

**Query params:** `programId` (required), `onlineSessionId` (optional — when given, response includes each registration's `activationStatus`/`joinUrl` for that session), `activationStatus` filter (`active`/`inactive`/`all`, only meaningful with `onlineSessionId`), `page`, `limit`, `search`.

**Filter logic:** `seatAllocated = true AND registrationStatus NOT IN (cancelled, rejected, archived)`, joined to `ProgramRegistrationOnlineSession` on the given session if present.

**Response (session-scoped example):**
```json
{
  "success": true,
  "data": {
    "items": [
      {
        "registrationId": 6839,
        "fullName": "Madhuri Karedla",
        "email": "madhuri@example.com",
        "seatAllocated": true,
        "activationStatus": "active",
        "joinUrl": "https://zoom.us/j/...",
        "activationChangedAt": null
      }
    ],
    "total": 500,
    "page": 1,
    "limit": 50
  }
}
```

### 4.2 `PATCH /registration/:id/online-session/:onlineSessionId/activation` — toggle active/inactive

**Body:** `{ "activationStatus": "active" | "inactive", "reason"?: "string" }`

**Validation/flow:**
```text
1. Load ProgramRegistrationOnlineSession for (registrationId, onlineSessionId).
   └── Not found → 404 ROS_NF_001
2. Load registration.seatAllocated
   └── false → 400 ROS_BR_001 "Registration is not seat-eligible"
3. Load the session's ProgramSession.startsAt / endsAt.
   └── NOW() is within [startsAt, endsAt] → 409 ROS_CF_001 "Cannot change activation while session is in progress"
4. requested status === current activationStatus → 200 idempotent no-op (don't error; smoother for UI retries)
5. Transaction:
   a. INACTIVE: call handlerFor(session).removeParticipant(session, extension) [existing low-level Zoom DELETE call]
      → on success: set activationStatus=INACTIVE, joinUrl=null, activationChangedAt/By/reason
      → atomically decrement OnlineSession.eligible_active_count (floored at 0)
   b. ACTIVE: call handlerFor(session).addParticipant(session, contact, role) [existing low-level Zoom POST call]
      → on success: set activationStatus=ACTIVE, joinUrl=<new>, externalRegistrantId=<new>, activationChangedAt/By/reason
      → atomically increment OnlineSession.eligible_active_count
   c. Zoom call throws → rollback DB changes, map to 502/400 ROS_BR_003 "Failed to update provider registration" (per error-handling.md: no generic catch-all, map to a custom exception)
```

**Why not reuse `ZoomRegistrationService.handle()`:** its `register()` throws `ZOOM_USER_ALREADY_REGISTERED` when a non-deleted extension row already exists (ours always does, since we never soft-delete on deactivation), and its `unregister()` soft-deletes the row (we need it to persist so it can be reactivated). So this new service calls the **low-level** `MeetingService`/`WebinarService.addParticipant`/`removeParticipant` directly and owns the row update itself.

**Response:**
```json
{
  "success": true,
  "data": {
    "registrationId": 6839,
    "onlineSessionId": 412,
    "activationStatus": "inactive",
    "joinUrl": null,
    "activationChangedAt": "2026-07-13T10:00:00Z",
    "activationChangedBy": 91
  }
}
```

### 4.3 `GET /online-session/:id/eligible-count`

**Response:**
```json
{
  "success": true,
  "data": {
    "onlineSessionId": 412,
    "eligibleActiveCount": 493,
    "totalEligible": 500,
    "inactiveCount": 7
  }
}
```

---

## 5. New error codes (registry additions)

Following the existing `<PREFIX>_<NF|BR|CF>_<seq>` scheme in `src/common/constants/error-string-constants.ts`:

| Code | Meaning |
|---|---|
| `ROS_NF_001` | Registration/online-session pair not found |
| `ROS_BR_001` | Registration is not seat-eligible |
| `ROS_CF_001` | Cannot change activation while session is in progress |
| `ROS_BR_003` | Provider (Zoom) registrant add/remove failed |

Add matching entries to `common/i18n/error-messages.ts`.

---

## 6. Where the code lives

- New service + controller: recommend a **new focused module**, e.g. `src/registration-eligibility/` — the closest existing module, `src/registration-session-info/`, is a **read-only builder** (`RegistrationSessionInfoService.buildForRegistrations()`) used by registration list/detail responses; mixing mutation logic into it would blur its single responsibility. The new module depends on: `ProgramRegistrationOnlineSessionRepository`, `OnlineSessionService`, `MeetingService`/`WebinarService` (from `ZoomModule`, already exported), and `SeatAvailabilityService`/seat utils for the eligibility filter.
- New util `src/common/utils/eligible-count.util.ts` mirroring `seat-count.util.ts`'s atomic increment/decrement pattern, scoped to `OnlineSession.eligible_active_count`.
- Entity/enum changes: `src/common/entities/program-registration-online-session.entity.ts`, `src/common/entities/online-session.entity.ts`, new `src/common/enum/registration-online-session-activation-status.enum.ts`.
- Error registry: `src/common/constants/error-string-constants.ts`, `src/common/i18n/error-messages.ts`.

---

## 7. Open questions to confirm before implementation

1. **"Eligible" scope for the list API (§4.1):** should it always require an `onlineSessionId` (session-scoped view, matching how the toggle works), or also support a program-wide view with no session filter? Plan above supports both; confirm which the UI needs first so we can ship the smaller slice.
2. **Live-session boundary:** using `ProgramSession.startsAt`/`endsAt` as the "in progress" window. If a Zoom session overruns past `endsAt`, `OnlineSession.actualMeetingEndsAt` (set by the `meeting.ended`/`webinar.ended` webhook) would be a more accurate end signal. Plan defaults to `startsAt`/`endsAt`; flag if webhook-driven end time should take precedence.
3. **Notifications:** should the seeker get an email/SMS when they're marked inactive (losing access) or reactivated (new join link)? Not in the original 6 requirements — confirming before deciding whether to add a notification hook to the toggle flow.
4. **Reactivation join link:** plan assumes Zoom always issues a fresh join URL on re-`addParticipant` (this is standard Zoom registrant behavior) — no attempt to reuse an old link.

---

## 8. Implementation phases (once approved)

1. **Data model** — migrations, entity/enum changes, backfill `eligible_active_count`.
2. **Eligible list API** (§4.1) — read-only, no Zoom interaction, lowest risk, ships first.
3. **Activation toggle, DB-only** (§4.2 minus the Zoom calls) — status flip + live-session guard, behind a flag if needed.
4. **Wire in Zoom add/remove** — low-level `addParticipant`/`removeParticipant` calls inside the transaction, with rollback on provider failure.
5. **Eligible-count endpoint** (§4.3) + atomic counter wiring + reconciliation query for drift.
6. **Tests** — Jest unit tests (eligibility filter, toggle happy path, live-session block, provider-failure rollback, idempotent same-state toggle) + Supertest integration tests (3 allowed roles pass, 1 disallowed role → 403, 404/409 cases), per `.claude/rules/backend/testing.md`.
