# Program Session & Scheduled Join-Link Generation

**Module:** `src/program-session`
**Related modules:** `src/join-link-scheduler`, `src/join-link-generation`, `src/aws-scheduler`, `src/queue`, `src/online-session`
**Status:** Active
**Last updated:** 2026-07-07

---

## 1. What this feature is

A **Program Session** is a single occurrence within a Program (an opening session, a live day, a webinar slot, etc.). It carries everything needed to run and bill that occurrence: schedule (`startsAt`/`endsAt`), venue / online details, seat counts, pricing & tax, invoice metadata, email/branding config, and lifecycle `status`.

On top of ordinary CRUD, this feature adds one non-trivial capability:

> **Scheduled bulk "join-link" generation** — an admin picks a future time; at that time the system automatically generates online meeting join-links for every registrant of the session, without anyone clicking a button.

That scheduled piece is the interesting part of this document. The CRUD is covered first for context, then the scheduled flow is traced step-by-step, then every branch/edge case is enumerated.

---

## 2. Architecture at a glance

```
Controller  → Service            → Repository        → DB (hdb_program_session)
(HTTP/DTO)    (business + txns)     (TypeORM only)

Link scheduling side-path:
Service.setLinkGeneration
   ├─ Repository.setLinkGenerationAt   (persist time + status)
   └─ JoinLinkSchedulerService         (AWS EventBridge Scheduler one-time schedule)
                     │  at fire time
                     ▼
              SQS queue  →  SqsPollerService  →  JoinLinkGenerationProcessor
                     │
                     ▼
              JoinLinkGenerationService.generateForSession
                     ├─ ProgramSessionService.markLinkGenerationStatus (IN_PROGRESS/COMPLETED/FAILED)
                     └─ OnlineSessionService.bulkRegister  (actual link creation, async job)
```

Layering follows the repo rules: controllers hold no business logic, services own transactions, repositories are the only place touching TypeORM. See [.claude/rules/backend.md].

**Files:**

| File | Role |
|------|------|
| [program-session.controller.ts](../src/program-session/program-session.controller.ts) | HTTP endpoints, DTO validation, response envelope |
| [program-session.service.ts](../src/program-session/program-session.service.ts) | Business logic, transaction boundaries, scheduler orchestration |
| [program-session.repository.ts](../src/program-session/program-session.repository.ts) | All DB access (TypeORM) |
| [dto/create-program-session.dto.ts](../src/program-session/dto/create-program-session.dto.ts) | Create payload + validation |
| [dto/update-program-session.dto.ts](../src/program-session/dto/update-program-session.dto.ts) | Partial update payload |
| [dto/set-link-generation.dto.ts](../src/program-session/dto/set-link-generation.dto.ts) | Link-generation time payload |
| [join-link-scheduler.service.ts](../src/join-link-scheduler/join-link-scheduler.service.ts) | Domain wrapper over AWS Scheduler (flag, config, message shape) |
| [join-link-generation.service.ts](../src/join-link-generation/join-link-generation.service.ts) | Runs the bulk generation + drives lifecycle status |
| [join-link-generation.processor.ts](../src/queue/processors/join-link-generation.processor.ts) | Consumes the SQS message at fire time |

---

## 3. Authentication & authorization

Every endpoint on the controller is guarded:

```ts
@UseGuards(CombinedAuthGuard, RolesGuard)
@Roles('admin')
```

So **all** program-session operations require an authenticated **admin**. The acting user id is taken from `req.user?.id` and stamped into `createdBy` / `updatedBy` (falls back to the value in the DTO only if the guard did not populate `req.user`).

---

## 4. Data model & lifecycle status

Key fields on `ProgramSession` relevant to this feature:

| Field | Meaning |
|-------|---------|
| `programId` | Parent program (validated to exist & not soft-deleted) |
| `name`, `code`, `displayOrder` | Identity / ordering within the program |
| `startsAt`, `endsAt`, `blessEndsAt`, `canRegisterTill` | Schedule windows |
| `modeOfOperation` | `online` \| `offline` \| `hybrid` |
| `status` | `scheduled` \| `active` \| `completed` \| `cancelled` \| `postponed` |
| `onlineSession` | 1:1 row (`hdb_online_session`) holding meeting/webinar/stream details, cascade-persisted |
| `linkGenerationAt` | Future time to auto-generate join-links (nullable) |
| `linkGenerationStatus` | Lifecycle marker for the scheduled generation |
| `deletedAt`, `createdBy`, `updatedBy` | Soft-delete + audit fields |

### Link-generation lifecycle (`JoinLinkGenerationStatus`)

```
NOT_SCHEDULED → SCHEDULED → IN_PROGRESS → COMPLETED
                                       ↘  FAILED
```

- **NOT_SCHEDULED** — no time set (or cleared).
- **SCHEDULED** — a time is set and an AWS schedule is registered.
- **IN_PROGRESS** — the schedule fired and generation is running.
- **COMPLETED** — bulk registration was dispatched successfully.
- **FAILED** — generation threw; the SQS message is retried (up to `MAX_RETRY_ATTEMPTS`), and the 4-hourly fallback cron will also re-attempt it.

Defined in [join-link-generation-status.enum.ts](../src/common/enum/join-link-generation-status.enum.ts). Note the status is a **thin marker** — per-registrant success/failure lives on the bulk background job, not here.

---

## 5. API endpoints

All routes are under `/program-session` and require `admin`.
> ⚠️ Send bodies as `Content-Type: application/json` — the `ValidationPipe` uses `whitelist: true` and will silently strip form-data fields.

| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/program-session` | Create a session |
| `GET` | `/program-session` | List sessions (paginated, filterable) |
| `GET` | `/program-session/:id` | Get one session (with program, online, audit relations) |
| `PUT` | `/program-session/:id` | Update a session |
| `PATCH` | `/program-session/:id/link-generation` | Set/clear scheduled link generation |
| `DELETE` | `/program-session/:id` | Soft-delete a session |

### 5.1 Create — `POST /program-session`

1. `ValidationPipe({ transform: true, whitelist: true })` validates the DTO.
2. Controller stamps `createdBy`/`updatedBy` from `req.user.id`.
3. Service opens a **transaction** and calls `repository.createSession`.
4. Repository:
   - validates the program exists (404 `PROGRAM_NOTFOUND` if not),
   - validates creator & updater users exist (404 `..._CREATOR_NOTFOUND` / `..._UPDATOR_NOTFOUND`),
   - builds the `ProgramSession`, maps venue address, and maps online-detail shapes (`meetingDetails`/`webinarDetails`/`streamDetails`) into a 1:1 `OnlineSession` row (cascade-saved),
   - saves.
5. Returns a trimmed payload: `{ id, name, code, programId, displayOrder, status }`.

### 5.2 List — `GET /program-session`

Query params: `limit` (default 10), `offset` (default 0), `searchText`, `programId` (JSON array string), `filters` (URL-encoded JSON).

- `programId` is `JSON.parse`d and **must be an array** → else `400 "Program ID must be an array"`.
- `filters` supports `status` and `modeOfOperation`.
- `searchText` does a case-insensitive `ILIKE` on `name`.
- Returns `{ data, pagination: { totalPages, pageNumber, pageSize, totalRecords, numberOfRecords } }`.

### 5.3 Get one — `GET /program-session/:id`

`id` is `ParseIntPipe`-validated. Loads program (+ question maps), creator, updater, and `onlineSession`. `404 PROGRAM_SESSION_NOTFOUND` if missing / soft-deleted.

### 5.4 Update — `PUT /program-session/:id`

Transaction-wrapped. Re-validates program (if `programId` sent) and updater, `Object.assign`s the DTO, re-syncs the `OnlineSession` row, saves. Returns trimmed payload including nested `program: { id, name }`.

### 5.5 Set link generation — `PATCH /program-session/:id/link-generation`

The headline feature. Body: `{ linkGenerationAt: string | null }` (future ISO 8601 UTC, or `null` to clear). Detailed flow in §6.

### 5.6 Delete — `DELETE /program-session/:id`

Requires `req.user` (else `401`). **Soft delete**: sets `deletedAt`, `updatedBy`, `updater`; row is retained. (Per repo rules, no hard deletes / no `ON DELETE CASCADE`.)

---

## 6. Scheduled join-link generation — step by step

This is the "first → next → next" walkthrough of the whole flow.

### Step 1 — Admin sets a time
`PATCH /program-session/:id/link-generation` with `{ "linkGenerationAt": "2026-07-10T09:30:00Z" }`.

`SetLinkGenerationDto` validation:
- `linkGenerationAt` is optional; if not `null` it must be a valid ISO date string (`@ValidateIf(o => o.linkGenerationAt !== null)` + `@IsDateString`).
- `null` is explicitly allowed and means **clear/cancel**.

### Step 2 — Service resolves intent
`ProgramSessionService.setLinkGeneration(id, linkGenerationAt, userId)`:
1. Calls `findOne(id)` → **404** if the session doesn't exist. (Guard before any scheduling.)
2. Derives status: value present → `SCHEDULED`, `null` → `NOT_SCHEDULED`.

### Step 3 — Persist the time + status
`repository.setLinkGenerationAt(id, linkGenerationAt, status, userId)` does a **direct column `UPDATE`** of `linkGenerationAt`, `linkGenerationStatus`, `updatedBy`.

> ⚠️ **Why a direct `UPDATE` and not load-and-save?** Loading a `ProgramSession` and re-saving it would run the `onlineSession` cascade with an unloaded relation and **null out** the `hdb_online_session` FK, tripping the `uq_online_session_program_id_template` unique constraint (23505). Every bulk/column update in this repository uses direct `.update()` for exactly this reason. See memory note *ProgramSession cascade FK null-out*.

### Step 4 — Register (or delete) the AWS schedule
Still in `setLinkGeneration`:
- If a time was given → `linkScheduler.createLinkSchedule({ programSessionId, fireAt, actorUserId })`.
- If `null` → `linkScheduler.deleteLinkSchedule(id)`.

`JoinLinkSchedulerService` (best-effort domain wrapper over `AwsSchedulerService`):
- **Feature-flag / config guard** (`ensureReady`): if `ENABLE_JOIN_LINK_SCHEDULER !== 'true'`, or the target queue ARN / execution role ARN aren't configured → logs and **no-ops** (returns without touching AWS). Missing env never crashes the request.
- Builds a **deterministic schedule name** `join-link-<programSessionId>` → so create/update/delete are idempotent (a re-set replaces the existing schedule rather than duplicating).
- Calls `awsScheduler.upsertOneTimeSchedule(...)` with a one-time EventBridge schedule that targets an SQS queue, with retry policy `maxAttempts=5`, `maxEventAgeSeconds=86400 (24h)`.

### Step 5 — Response
Returns `{ id, linkGenerationAt, linkGenerationStatus }`. Scheduling failures are swallowed (logged) — they do **not** fail the API call, because the fallback cron is the safety net.

### Step 6 — The schedule fires
At `fireAt`, EventBridge delivers a `join-link-generation` message onto the SQS queue:

```json
{
  "queueType": "EVENT",
  "subType": "JOIN_LINK_GENERATION",
  "timestamp": "<fireAt>",
  "correlationId": "join-link-<id>",
  "data": { "programSessionId": <id>, "role": ..., "batchSize": ..., "actorUserId": ... }
}
```

### Step 7 — Consume the message
`SqsPollerService` picks it up and routes it to `JoinLinkGenerationProcessor.process`:
- Type-guards `queueType/subType` → non-matching = non-retryable failure.
- Requires `programSessionId` → missing = non-retryable `VALIDATION_ERROR`.
- Delegates to `JoinLinkGenerationService.generateForSession`.
- Classifies thrown errors as retryable / non-retryable (drives SQS redelivery).

### Step 8 — Generate the links
`JoinLinkGenerationService.generateForSession(programSessionId, opts)`:
1. Marks status **IN_PROGRESS**.
2. Calls `onlineSessionService.bulkRegister({ sessionId, role: opts.role ?? ATTENDEE, batchSize }, actorUserId)` — this enqueues the actual per-registrant link creation as a background job.
3. On success marks **COMPLETED** and logs `jobId` + `total`.
4. On error marks **FAILED** and **rethrows** (so the processor reports failure → SQS retry).

> **Idempotency:** no locking is used. SQS single-delivers each message and `bulkRegister` is idempotent — already-registered pairs are skipped and the registration↔session unique index blocks duplicates. So a retry or a duplicate fire is safe.

### Step 9 — Safety nets: SQS retry + fallback cron
There is **no DLQ**. Two mechanisms recover a generation that didn't complete:

1. **SQS delivery retry** — `MAX_RETRY_ATTEMPTS = 5`, `MAX_EVENT_AGE_SECONDS = 86400` (24h). Handles transient failures of a delivered event.
2. **4-hourly fallback cron** ([join-link-generation-fallback.service.ts](../src/join-link-generation/join-link-generation-fallback.service.ts)) — handles the cases SQS retry can't: the AWS schedule was never registered (flag off / config missing), the event aged out, or a run crashed mid-flight. It sweeps the DB for sessions whose `linkGenerationAt` is overdue and whose `linkGenerationStatus` is not `COMPLETED`, and re-attempts each. Gated by `ENABLE_JOIN_LINK_FALLBACK_CRON` (default `false`).

### Step 10 — Concurrency: event vs. cron on the same session
Because both the AWS event and the fallback cron can target the same session, `generateForSession` funnels **every** attempt through a single **atomic claim** ([`claimForLinkGeneration`](../src/program-session/program-session.repository.ts)):

```sql
UPDATE hdb_program_session
   SET link_generation_status = 'IN_PROGRESS', updated_at = now()
 WHERE id = :id AND deleted_at IS NULL
   AND ( link_generation_status IN ('SCHEDULED','FAILED')
      OR (link_generation_status = 'IN_PROGRESS' AND updated_at < :staleBefore) )
```

- The `UPDATE` is a single atomic statement — **exactly one** caller can transition the row, so only one of {event, cron, duplicate SQS delivery} ever proceeds. The winner runs; the loser sees `affected = 0`, logs "already claimed", and **no-ops** (returns without throwing, so a losing event does not trigger an SQS retry).
- This matters because `startBulkRegistration` does **not** dedupe jobs — without the claim, two triggers would spawn two background jobs doing redundant work (data integrity would still hold via the registration↔session unique index, but at the cost of wasted work + constraint-violation noise).
- **Grace window** — the cron only selects sessions whose fire time is older than `GRACE_MS` (30 min), so it never even attempts a just-fired event that is still in flight.
- **Stale recovery** — a crashed run stuck at `IN_PROGRESS` becomes re-claimable once `updated_at` is older than `STALE_MS` (30 min). A healthy run flips to `COMPLETED`/`FAILED` in seconds, so this only reclaims genuinely stuck sessions.

---

## 7. Flow diagrams

### 7.1 End-to-end flow

```mermaid
flowchart TD
    A["Admin: PATCH /program-session/:id/link-generation"] --> B{"linkGenerationAt null?"}

    B -- "null (clear)" --> C["status = NOT_SCHEDULED<br/>direct UPDATE, cascade-safe"]
    C --> C2["scheduler.deleteLinkSchedule(id)"]
    C2 --> RESP["200 response: id, linkGenerationAt, status"]

    B -- "future ISO time" --> D{"session exists?"}
    D -- "no" --> E["404 PROGRAM_SESSION_NOTFOUND"]
    D -- "yes" --> F["status = SCHEDULED<br/>persist linkGenerationAt, direct UPDATE"]
    F --> G["scheduler.createLinkSchedule"]
    G --> H{"ensureReady: flag on and ARNs set?"}
    H -- "no" --> I["no-op, logged<br/>cron is the safety net"]
    H -- "yes" --> J["AWS EventBridge one-time schedule<br/>named join-link-[id]"]
    I --> RESP
    J --> RESP

    J -. "at fireAt" .-> K["SQS message JOIN_LINK_GENERATION"]
    K --> L["SqsPoller to JoinLinkGenerationProcessor"]
    L --> M{"valid type and programSessionId?"}
    M -- "no" --> N["non-retryable failure, dropped"]
    M -- "yes" --> GEN

    CRON["Fallback cron, every 4h"] --> CF{"ENABLE_JOIN_LINK_FALLBACK_CRON?"}
    CF -- "no" --> CX["log and return"]
    CF -- "yes" --> CQ["findDueForFallbackGeneration<br/>overdue past grace, not COMPLETED,<br/>or stale IN_PROGRESS"]
    CQ --> CL["for each candidate"]
    CL --> GEN

    GEN["generateForSession(id)"] --> CLAIM{"claimForLinkGeneration<br/>atomic UPDATE to IN_PROGRESS"}
    CLAIM -- "affected = 0, lost claim" --> SKIP["skip, no-op<br/>another worker owns it"]
    CLAIM -- "affected = 1, won claim" --> BR["onlineSession.bulkRegister<br/>background job dispatched"]
    BR -- "ok" --> DONE["status = COMPLETED"]
    BR -- "throws" --> FAIL["status = FAILED then rethrow"]
    FAIL --> RETRY["SQS retry, max 5<br/>or next 4h cron sweep"]
    RETRY -. "re-deliver" .-> GEN
```

### 7.2 Event-vs-cron concurrency (the atomic claim)

```mermaid
sequenceDiagram
    participant EV as AWS event via SQS
    participant CR as Fallback cron
    participant DB as hdb_program_session
    participant BULK as bulkRegister

    Note over EV,CR: Both target the same session at the same time

    EV->>DB: UPDATE SET IN_PROGRESS WHERE status IN (SCHEDULED, FAILED)
    CR->>DB: UPDATE SET IN_PROGRESS WHERE status IN (SCHEDULED, FAILED)

    Note over DB: Single atomic UPDATE - one row, one winner

    DB-->>EV: affected = 1, won
    DB-->>CR: affected = 0, lost

    EV->>BULK: generate links, one job
    CR-->>CR: log already claimed, then no-op

    BULK-->>EV: dispatched
    EV->>DB: status = COMPLETED
```

**Guarantees:**

- Every trigger path (event, cron, duplicate SQS delivery) converges on `generateForSession` → the **single atomic claim**. Only one wins; no double bulk jobs.
- The cron only reaches `generateForSession` for sessions past the **grace window** and not `COMPLETED`, so it never races a fresh event unnecessarily.
- `FAILED` loops back via SQS retry or the next cron sweep; a crashed `IN_PROGRESS` becomes reclaimable after the stale window.

### 7.3 Lifecycle state machine

Shows the same guarantees as state transitions — who moves the status, and how `FAILED` / stale `IN_PROGRESS` loop back to be re-attempted.

```mermaid
stateDiagram-v2
    [*] --> NOT_SCHEDULED
    NOT_SCHEDULED --> SCHEDULED: admin sets linkGenerationAt
    SCHEDULED --> NOT_SCHEDULED: admin clears with null

    SCHEDULED --> IN_PROGRESS: claim won by event or cron
    FAILED --> IN_PROGRESS: reclaim via SQS retry or cron
    IN_PROGRESS --> IN_PROGRESS: stale reclaim after STALE_MS

    IN_PROGRESS --> COMPLETED: bulkRegister dispatched
    IN_PROGRESS --> FAILED: error, rethrown

    COMPLETED --> [*]

    note right of IN_PROGRESS
        A lost claim (affected = 0)
        changes nothing; the loser no-ops.
    end note
```

### 7.4 Configuration & gating

The two feature flags independently gate the two trigger paths; timing constants tune the windows. Both flags default `false`, so nothing runs until enabled.

```mermaid
flowchart LR
    SET["linkGenerationAt persisted in DB<br/>source of truth, always happens"]

    SET --> F1{"ENABLE_JOIN_LINK_SCHEDULER and ARNs set?"}
    F1 -- "yes" --> P1["AWS real-time event path<br/>fires at linkGenerationAt"]
    F1 -- "no" --> X1["no AWS schedule, no-op logged"]

    SET --> F2{"ENABLE_JOIN_LINK_FALLBACK_CRON?"}
    F2 -- "yes" --> P2["4-hourly sweep path<br/>catches overdue sessions"]
    F2 -- "no" --> X2["no sweep"]

    P1 --> GEN["generateForSession, atomic claim"]
    P2 --> GEN

    subgraph CONST["Timing constants, code not env"]
        direction TB
        C1["FALLBACK_CRON = 0 every-4-hours"]
        C2["GRACE_MS = 30 min: cron will not steal a fresh event"]
        C3["STALE_MS = 30 min: reclaim crashed IN_PROGRESS"]
        C4["FALLBACK_BATCH_LIMIT = 50 sessions per tick"]
        C5["SQS: MAX_RETRY_ATTEMPTS = 5, MAX_EVENT_AGE = 24h"]
    end
```

> If **both** flags are off, `linkGenerationAt` is still saved but nothing generates links — enable at least one path. Running cron-only (no AWS) is valid: the sweep becomes the primary driver.

---

## 8. Configuration

From [join-link-scheduler.config.ts](../src/common/config/join-link-scheduler.config.ts) and [join-link-scheduler.constants.ts](../src/common/constants/join-link-scheduler.constants.ts):

| Env var | Purpose | Default |
|---------|---------|---------|
| `ENABLE_JOIN_LINK_SCHEDULER` | Real-time AWS scheduling flag (`'true'` to enable) | `false` |
| `ENABLE_JOIN_LINK_FALLBACK_CRON` | 4-hourly fallback-cron flag (`'true'` to enable) | `false` |
| `AWS_SCHEDULER_TARGET_QUEUE_ARN` | SQS queue the schedule delivers to | — (required to actually schedule) |
| `AWS_SCHEDULER_EXECUTION_ROLE_ARN` | IAM role EventBridge assumes | — (required) |
| `AWS_SCHEDULER_GROUP_NAME` | Scheduler group | optional |

Retry/timing policy is **code constant**, not env:
- SQS retry: `MAX_RETRY_ATTEMPTS = 5`, `MAX_EVENT_AGE_SECONDS = 86400`.
- Fallback cron ([join-link-generation.constants.ts](../src/common/constants/join-link-generation.constants.ts)): `FALLBACK_CRON = '0 */4 * * *'`, `GRACE_MS = 30 min`, `STALE_MS = 30 min`, `FALLBACK_BATCH_LIMIT = 50` sessions/tick.

> Both flags default `false`, so the feature is inert until explicitly enabled — no boot validation, per repo convention. Enable `ENABLE_JOIN_LINK_FALLBACK_CRON` even when running without AWS: the cron then becomes the *primary* driver of link generation off the persisted `linkGenerationAt`.

> Per repo convention, missing env vars must **not** crash boot — flags default to `false` and unconfigured scheduling degrades to a no-op (the cron covers it). See memory note *Default missing env to false*.

---

## 9. All possible cases

### Set-link-generation cases

| Case | Input / condition | Outcome |
|------|-------------------|---------|
| Schedule a valid future time | `linkGenerationAt` = future ISO | status `SCHEDULED`, time persisted, AWS schedule `join-link-<id>` created |
| Reschedule | `linkGenerationAt` = a different time | schedule **replaced** (same deterministic name → upsert), status stays `SCHEDULED` |
| Clear / cancel | `linkGenerationAt` = `null` | status `NOT_SCHEDULED`, time cleared, AWS schedule deleted |
| Session doesn't exist | any | `404 PROGRAM_SESSION_NOTFOUND` (checked before scheduling) |
| Invalid date string | non-ISO, non-null | `400` DTO validation error |
| Feature flag off | `ENABLE_JOIN_LINK_SCHEDULER!=true` | time still persisted; AWS call **no-ops** (logged); fallback cron will handle (if enabled) |
| Missing AWS config | flag on but ARNs unset | time persisted; scheduling skipped with a warning; cron handles (if enabled) |
| AWS scheduler throws | transient AWS error | error swallowed + logged; API still returns success; cron handles (if enabled) |
| Clear when no schedule exists | `null` on a session never scheduled | delete is a no-op (idempotent) |

### Fire-time / processing cases

| Case | Outcome |
|------|---------|
| Message wrong type/subtype | non-retryable failure, dropped |
| Message missing `programSessionId` | non-retryable `VALIDATION_ERROR` |
| Generation succeeds | claim `SCHEDULED`→`IN_PROGRESS`, then `COMPLETED`, bulk job dispatched |
| Generation throws | `FAILED`, rethrown → SQS retry (up to 5) → fallback cron |
| Duplicate / retried delivery | loser fails the atomic claim → no-ops; winner runs. `bulkRegister` idempotent + unique index as second guard |
| **Event and cron fire together** | atomic claim lets **only one** proceed; the other no-ops (no double job) |
| Schedule never registered (flag off etc.) | 4-hourly fallback cron picks it up from persisted state (if enabled) |
| Event older than 24h | dropped by `maxEventAgeSeconds`; fallback cron is the net |
| Run crashes mid-flight (stuck `IN_PROGRESS`) | reclaimable after `STALE_MS` (30 min); cron re-attempts |
| Fallback cron disabled | `ENABLE_JOIN_LINK_FALLBACK_CRON!=true` → sweep logs + returns; only SQS retry applies |
| One session fails during a sweep | logged, marked `FAILED`; the sweep continues with the rest |

### CRUD edge cases

| Case | Outcome |
|------|---------|
| Create with unknown `programId` | `404 PROGRAM_NOTFOUND` |
| Create/update with unknown creator/updater user | `404 ..._CREATOR_NOTFOUND` / `..._UPDATOR_NOTFOUND` |
| List with non-array `programId` | `400 "Program ID must be an array"` |
| Get / update / delete unknown id | `404 PROGRAM_SESSION_NOTFOUND` |
| Delete without authenticated user | `401 Unauthorized` |
| Delete existing session | soft delete (`deletedAt` set), row retained |
| Any bulk column update (status/delete/link fields) | direct `.update()` — never load-and-save (avoids online-session cascade FK null-out) |

---

## 10. Design notes & gotchas

1. **Best-effort scheduling, guaranteed by cron.** The API never fails because of AWS. The source of truth is the persisted `linkGenerationAt` + `linkGenerationStatus`; the 4-hourly cron reconciles anything the real-time schedule missed.
2. **One atomic claim gates every trigger.** The event, the cron, and duplicate SQS deliveries all funnel through `claimForLinkGeneration` — a single conditional `UPDATE`. Only one can win, so a session is never double-generated. `startBulkRegistration` does *not* dedupe jobs, so this gate (not the bulk layer) is what prevents redundant background jobs.
3. **Direct `UPDATE` everywhere for column tweaks.** Loading + saving `ProgramSession` risks nulling the `hdb_online_session` FK via cascade → unique-constraint 23505. All status/delete/link-field mutations (incl. the claim) use `sessionRepo.update(...)` / query-builder update.
4. **Deterministic schedule names** (`join-link-<id>`) make create/update/delete idempotent — no orphaned or duplicated schedules.
5. **Thin lifecycle status.** `linkGenerationStatus` only tracks the *dispatch*; per-registrant results live on the bulk background job.
6. **No DLQ by design** — SQS retry + the 4-hourly fallback cron replace it.
7. **Stale-IN_PROGRESS recovery.** A crashed run is reclaimed after `STALE_MS`; the grace window (`GRACE_MS`) stops the cron from stealing a just-fired event.
8. **Soft delete only** — no hard deletes, no `ON DELETE CASCADE` in schema (repo rule).

---

## 11. Error codes referenced

From `src/common/constants/error-string-constants.ts`:

- `PROGRAM_SESSION_SAVE_FAILED`
- `PROGRAM_SESSION_GET_FAILED`
- `PROGRAM_SESSION_FIND_BY_ID_FAILED`
- `PROGRAM_SESSION_DELETE_FAILED`
- `PROGRAM_SESSION_NOTFOUND`
- `PROGRAM_SESSION_CREATOR_NOTFOUND`
- `PROGRAM_SESSION_UPDATOR_NOTFOUND`
- `PROGRAM_NOTFOUND`

All responses use the centralized envelope `{ success, data, error }`; internal details / stack traces are never exposed.
