# Communication Template Configuration — Technical Spec

Status: Draft (rev 11 — per-step attachment stored on the clone as `attached_steps`)
Last updated: 2026-05-29

---

## Rev 11 — `attached_steps` on the clone (SUPERSEDES rev 10's junction table)

> Rev 10 introduced a separate `program_step_template_map` junction table to model
> per-(program, step, master) attachment. **Rev 11 replaces that** with a `text[]`
> column `attached_steps` on `hdb_communication_templates`. The clone stays 1:1 with
> master per program — no second table. Cross-step attachment lives inside the array.
> Where the rev-10 sections below conflict with rev 11, rev 11 wins.

### R11.1 Requirement (unchanged from rev 10)

Each step shows a dropdown of **all** active templates for the program type. That step's
**native** templates (`master.step === currentStep`) are **pre-ticked**; the admin may
untick those and tick any other template for the step. A ticked template fires when that
step's lifecycle event occurs — including templates authored for a different step. The
same template can be attached to several steps for one program.

### R11.2 Data model — `attached_steps` on `hdb_communication_templates`

The clone gains a single column:

```sql
ALTER TABLE hdb_communication_templates
  ADD COLUMN attached_steps TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];

CREATE INDEX idx_hct_attached_steps_gin
  ON hdb_communication_templates USING GIN (attached_steps);
```

- **Per-(program, master) shape preserved** — one clone row per (program_id, master_template_id),
  same as today. The seven send-time consumer modules continue to read clones unchanged.
- `attached_steps` lists every step value this template is currently wired to. Native
  attachment is included by default; cross-step attachment adds extra step values to the
  array. Empty array + `deleted_at` set = removed across all steps.
- **Default seed on program create:** each clone starts with `attached_steps = ARRAY[master.step]`
  (native attachment). When the admin's create payload disables a master via the per-step
  selection, the clone starts with `attached_steps = ARRAY[]`. The `attachedSteps` field is then
  overlaid by `applyPerStepSelectionForProgram` to apply cross-step attachments.
- The rev-9 `hdb_communication_templates.is_enabled` column is **retired from the config path**
  (kept for back-compat). Send-time reads `attached_steps`.
- The rev-10 `program_step_template_map` table is **gone**. All per-step state lives on the clone.

### R11.3 Read payload (per step, all templates, native pre-ticked)

Both read endpoints return, **for every step**, the full template universe for the program type
with `isEnabled` = "attached for THIS step":

```ts
{ steps: [ { value, label, templates: [ { masterTemplateId, templateAccessKey, templateName,
  channel, targetRole, targetAudienceScope, templateId, sandboxTemplateId,
  isEnabled,           // true if step ∈ clone.attached_steps
  isNativeToStep       // true if this template's master.step === step.value (drives default + a "native" badge)
} ] } ] }
```

- `by-program-type`: `isEnabled = isNativeToStep` (preview of the seed for a brand-new program).
- `by-program`: `isEnabled` = step is present in `hdb_communication_templates.attached_steps`
  for the clone with this `(programId, masterTemplateId)`.

### R11.4 Save payload (per step)

```ts
communicationConfig: {
  steps: [
    { step: "BLESSED",                enabledMasterTemplateIds: [17,18,19,20] },
    { step: "REGISTRATION_COMPLETED", enabledMasterTemplateIds: [5,6,7,8,17] }, // 17 attached to two steps
    ...
  ]
}
```

- Validation: every `step` must be a real step for the program type; every id must be an active
  master of the program's `program_type_id` → else `400 INVALID_MASTER_TEMPLATE_IDS` (details list the offenders).
- On create: clone master rows (each clone gets `attached_steps = [master.step]` when enabled,
  `[]` when explicitly disabled by the payload), then call `applyPerStepSelectionForProgram` to
  layer cross-step attachments. On update: only `applyPerStepSelectionForProgram` runs — for each
  step in the payload, every master listed adds that step to its clone's array; every master not
  listed removes that step from its clone's array. Clones with empty arrays are soft-deleted;
  re-tick restores `deleted_at = null`.

### R11.5 Send-time — step-driven dispatch

Today each business flow sends **fixed** templates by access key (e.g. approval → `BLESSED_EMAIL_SEEKER`).
Rev 10 needs sends to honour "whatever is attached to this step". Delivery is **phased**:

- **Phase 1 (config + storage + preview):** ship R11.2–R11.4 and the read/save endpoints + UI.
  Native-step templates keep firing through the **existing** hard-coded call sites, now gated by
  checking the clone's `attached_steps` for its native step. Cross-step attachments are stored but
  not yet dispatched.
- **Phase 2 (step-driven dispatch):** add `dispatchTemplatesForStep(programId, step, context)` that
  finds clones where `:step = ANY(attached_steps)`, resolves recipients from `target_role` +
  `target_audience_scope`, resolves merge data, and sends per channel. Wire it into each lifecycle
  event across registration, approval, payment, invoice, scheduler, messages. This replaces the
  per-access-key gating.

**Known risk:** a foreign template fired at a step resolves its merge fields against that step's
context, which may be incomplete (e.g. an invoice template at blessed-time has no payment yet) →
blank/odd fields. Admin-curated combinations can render incompletely; not blocked by the system.

---

Scope: Backend APIs + schema changes to power **one frontend surface**:

**Add Program — Communication Configuration section** — admin views the program-level template rows in `hdb_communication_templates` (the existing per-program table), grouped by step, and toggles each one on/off for the program.

No Master Admin UI is built in this rollout. Master templates, merge field catalog entries, and program_template defaults are all managed via DB seeds + migrations. The schema is designed so that admin-facing endpoints can be added later without further migrations.

Storage decision: the per-program on/off bit lives on `hdb_communication_templates` (a new `is_enabled` column) — reusing the table that already holds program-scoped template rows. No new exclusion table.

---

## 1. Goal

Let admins choose which communication templates fire for a specific program. The Add Program page shows **one accordion per step**; under each accordion, a multi-select dropdown of templates that fire on that step (plus a list of currently-selected names). Admin ticks / unticks → bulk save. That's it.

### 1.1 Add Program — Communication Configuration section

When the admin creates / edits a program, the Add Program page shows **one accordion per step** that the program's master templates cover. Under each accordion: a multi-select dropdown listing the templates for that step + a list of currently-selected template names.

**Flow (new program — no `programId` yet):**

1. Admin picks `programTypeId` in the meta section of the form.
2. FE calls `GET /v1/communication-config/by-program-type/:programTypeId` to load steps + master templates with `isEnabled = master.is_default`.
3. Admin ticks / unticks templates. **No network call — selections live in local form state.**
4. On Add Program **form submit**, the FE includes `communicationConfig: { enabledMasterTemplateIds: [...] }` in the program create payload.
5. BE creates the program + runs auto-clone with admin's selections applied.

**Flow (edit existing program):**

1. FE calls `GET /v1/programs/:programId/communication-config` to load the current state (clones with their `is_enabled`).
2. Admin ticks / unticks templates. **No network call — selections live in local form state.**
3. On Add Program form submit, the FE includes the same `communicationConfig` payload in the program update.
4. BE updates `is_enabled` on the existing clone rows to match.

**Edits affect only that `program_id`'s clone rows** — they do not change `is_default` on master, so other programs of the same type are unaffected.

No per-program editing of `template_id`, `sandbox_id`, or merge_info — those are sourced from the existing clone fields. See [UI Spec](./COMMUNICATION_TEMPLATE_CONFIG_UI_SPEC.md) for full FE design.

### 1.2 Not in this rollout

- **Master Template Admin UI** — managing master templates, the merge field catalog, and program_template defaults is out of scope. All three are populated via DB seeds + migrations. The schema supports a future admin UI without additional migrations.

---

## 2. Key Design Decisions

### 2.0 Add Program is selection-only

Add Program does not edit template content. The admin's only action per template is "enable / disable for this program". Master is the single source of truth for `template_id`, `sandbox_template_id`, `merge_field_map`, body, subject, and the merge-key catalog. The existing clone semantics on `hdb_communication_templates` are unchanged — what we add is a single boolean column.

### 2.1 Storage = `is_enabled` column on `hdb_communication_templates`

Reuse the existing per-program table. Add one column:

```sql
ALTER TABLE hdb_communication_templates
  ADD COLUMN is_enabled BOOLEAN NOT NULL DEFAULT true;
```

Semantics:

- `is_enabled = true` → row fires when its step is hit.
- `is_enabled = false` → admin opted out for this program. Row stays in the table (preserving its `template_id` / `sandbox_template_id` snapshot) but is filtered out at send time.

Auto-clone of master → `hdb_communication_templates` on program creation **stays as it is today** (the existing `cloneMultipleMasterTemplatesToProgramByProgramType` flow), but the `is_enabled` value of each new clone is **derived from the program_template's defaults** (see §2.6). For programs created before this spec, all existing clone rows back-fill to `is_enabled = true` so current behaviour is preserved.

Rejected alternatives:

- New `program_communication_template_exclusion` table (earlier rev 4 design) — requires a new table when the existing one already holds the right shape.
- Repurposing `is_active` (doesn't exist on this table today, and even if it did, that name has soft-delete connotations elsewhere in the codebase).

### 2.6 Defaults live on the master row via `is_default`

Each master row has a boolean `is_default` indicating whether it should auto-enable on new programs of its `program_type`. Because master rows are already scoped to a single `program_type_id`, this single flag is enough to express "for HDB/MSD, these templates default-on; for TAT, this other set defaults-on; for Entrainment, this third set; etc."

```sql
ALTER TABLE communication_templates_master
  ADD COLUMN is_default BOOLEAN NOT NULL DEFAULT true;
```

Default value is `true` so every existing master row counts as a default on the first migration — preserves current send behaviour for programs created before this rollout. Curators flip to `false` for non-default templates over time, via DB updates.

Editing `is_default` on master **does not retroactively change existing programs' clones** — auto-clone only consults `is_default` at the moment a new program is created. Existing clones keep whatever `is_enabled` value they had.

### 2.2 Data-model invariant — one master row per (step, role, scope, channel)

A configuration row in `communication_templates_master` is uniquely identified by the tuple `(program_type_id, step, target_role, target_audience_scope, template_type)`. A single step fans out to N rows (e.g. `REGISTRATION_COMPLETED` can have rows for `(Seeker, ASSIGNED, EMAIL)`, `(Seeker, ASSIGNED, WHATSAPP)`, `(ROLE_RM, ASSIGNED, WHATSAPP)`, `(ROLE_RM, ALL, EMAIL)`, `(ROLE_FINANCE_MANAGER, ALL, EMAIL)`, `(ROLE_SHOBA, ALL, WHATSAPP)`). Add Program lists all of these under the step and offers one checkbox per row.

### 2.3 Step / Role columns added to master

Audience is currently encoded inside `template_access_key` (`REGISTRATION_COMPLETED_EMAIL_SEEKER`, `BLESSED_WATI_RM`). Parsing is brittle. Add explicit columns to `communication_templates_master`:

- `step` (varchar, new enum `CommunicationStepEnum`).
- `target_role` (varchar) — stores existing role keys from [`ROLE_KEYS` in strings-constants.ts:37-47](../../src/common/constants/strings-constants.ts#L37-L47): `ROLE_RM`, `ROLE_FINANCE_MANAGER`, `ROLE_SHOBA`, `ROLE_ADMIN`, `ROLE_MAHATRIA`. Plus the literal `'Seeker'` (already used at [strings-constants.ts:59](../../src/common/constants/strings-constants.ts#L59)) for the registrant. **No new role enum.**
- `target_audience_scope` (varchar, new enum `CommunicationAudienceScopeEnum`) — `ASSIGNED` or `ALL`.
- `channel` — **reuse existing** `template_type` column (`CommunicationTypeEnum`); do not add.

Existing rows are back-filled by parsing `template_access_key`. The access key remains the canonical identifier; the new columns drive UI filtering and grouping.

### 2.4 Global merge-key catalog table

A new table `merge_field_catalog` stores the global registry of backend-resolvable fields used by master `merge_field_map` JSONB. The Master Admin dropdown reads from it; admins can add new entries without a deploy.

### 2.5 User-chosen placeholder vs catalog key (Master Admin only)

In the Master Admin UI, the admin types any placeholder name (e.g. `dateofbirth`) and **maps it to a catalog entry** (e.g. `user.dob`). The saved `merge_field_map` JSONB row stores both plus a denormalized snapshot:

```json
{
  "keyName": "dateofbirth",
  "catalogKey": "user.dob",
  "sourceTable": "users",
  "sourceColumn": "dob",
  "dataType": "date",
  "formatType": "custom:DD-MM-YYYY",
  "defaultValue": "",
  "isPerRecipient": false,
  "isNullable": true,
  "description": "Date of Birth"
}
```

No change to `CommunicationMergeDataService` — it still resolves `sourceColumn` against `computedFunctionRegistry` or treats it as a DB column.

### 2.6 Master propagation (catalog edits)

When a catalog entry is edited via `PUT /config/merge-catalog/:id`, every master row whose `merge_field_map` references that catalog key must be re-snapshotted (the denormalized fields refresh). Wrapped in a transaction. No clones to update under the new model.

---

## 3. Schema Changes

### 3.1 `communication_templates_master` — add columns

```sql
ALTER TABLE communication_templates_master
  ADD COLUMN step                  VARCHAR(64)  NULL,
  ADD COLUMN target_role           VARCHAR(64)  NULL,
  ADD COLUMN target_audience_scope VARCHAR(16)  NOT NULL DEFAULT 'ASSIGNED',
  ADD COLUMN subject               VARCHAR(500) NULL,
  ADD COLUMN body                  TEXT         NULL,
  ADD COLUMN is_default            BOOLEAN      NOT NULL DEFAULT true;

CREATE UNIQUE INDEX uq_ctm_pt_ak_type_scope
  ON communication_templates_master (program_type_id, template_access_key, template_type, target_audience_scope)
  WHERE is_active = true;

CREATE INDEX idx_ctm_step_role_channel
  ON communication_templates_master (program_type_id, step, target_role, target_audience_scope, template_type)
  WHERE is_active = true;

CREATE INDEX idx_ctm_program_type_default
  ON communication_templates_master (program_type_id, is_default)
  WHERE is_active = true;
```

Back-fill rules (run inside the same migration):

| Old `template_access_key` pattern | `step` | `target_role` |
| --- | --- | --- |
| `REGISTRATION_COMPLETED_EMAIL_SEEKER` | `REGISTRATION_COMPLETED` | `Seeker` |
| `REGISTRATION_COMPLETED_WATI_RM` | `REGISTRATION_COMPLETED` | `ROLE_RM` |
| `BLESSED_NO_PAYMENT_WATI_RM` | `BLESSED_NO_PAYMENT` | `ROLE_RM` |
| `RM_HOLD_EMAIL_RM` | `RM_HOLD` | `ROLE_RM` |
| `REGISTRATION_CANCELLED_EMAIL_FINANCE` | `REGISTRATION_CANCELLED` | `ROLE_FINANCE_MANAGER` |
| `CO_ORDINATOR_TRAVEL_PLAN_CHANGE_WATI` | `CO_ORDINATOR_TRAVEL_PLAN_CHANGE` | `ROLE_SHOBA` |
| Legacy (`BLESSED`, `HOLD`, `INVOICE`, `SWAP_DEMAND`, `PREFERENCE_EDITED`, `ADMIN_MESSAGE_NOTIFICATION`, `BILLING_DETAILS_REMOVED`, `EINVOICE_ERROR`) | leave `NULL` | leave `NULL` |

All rows back-fill `target_audience_scope = 'ASSIGNED'`. Rows with NULL `step` / `target_role` are hidden from the new UI.

### 3.2 `merge_field_catalog` — new table

```sql
CREATE TABLE merge_field_catalog (
  id                BIGSERIAL PRIMARY KEY,
  catalog_key       VARCHAR(128) NOT NULL UNIQUE,
  display_name      VARCHAR(255) NOT NULL,
  source_table      VARCHAR(255) NOT NULL,
  source_column     VARCHAR(255) NOT NULL,
  data_type         VARCHAR(32)  NOT NULL,        -- MergeFieldDataTypeEnum
  format_type       VARCHAR(50)  NULL,
  default_value     TEXT         NULL,
  description       TEXT         NULL,
  is_per_recipient  BOOLEAN      NOT NULL DEFAULT false,
  is_active         BOOLEAN      NOT NULL DEFAULT true,
  created_at        TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
  updated_at        TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
  created_by        BIGINT       NULL,
  updated_by        BIGINT       NULL
);

CREATE INDEX idx_mfc_active ON merge_field_catalog (is_active);
CREATE INDEX idx_mfc_data_type ON merge_field_catalog (data_type);
```

### 3.3 `hdb_communication_templates` — add `is_enabled`, `master_template_id`, step / role / scope columns

```sql
ALTER TABLE hdb_communication_templates
  ADD COLUMN is_enabled            BOOLEAN      NOT NULL DEFAULT true,
  ADD COLUMN master_template_id    BIGINT       NULL REFERENCES communication_templates_master(id) ON DELETE SET NULL,
  ADD COLUMN step                  VARCHAR(64)  NULL,
  ADD COLUMN target_role           VARCHAR(64)  NULL,
  ADD COLUMN target_audience_scope VARCHAR(16)  NOT NULL DEFAULT 'ASSIGNED';

CREATE INDEX idx_hct_program_step_enabled
  ON hdb_communication_templates (program_id, step, is_enabled)
  WHERE is_enabled = true;

CREATE INDEX idx_hct_program_master ON hdb_communication_templates (program_id, master_template_id);
```

`master_template_id` is the explicit FK back to the master row this clone was made from. Used by the save path (Add Program update) to find clones by `(program_id, master_template_id)` and flip `is_enabled`. Mirror `step`, `target_role`, `target_audience_scope` onto the clone table so the Add Program UI can group rows by step without joining to master.

Back-fill on `hdb_communication_templates`:

- `step` + `target_role`: same parsing rules as the master back-fill in §3.1 (parse from `template_access_key`).
- `target_audience_scope`: default `'ASSIGNED'`.
- `is_enabled`: stays `true` for every existing row.
- `master_template_id`: populate via subquery — `UPDATE hdb_communication_templates h SET master_template_id = m.id FROM communication_templates_master m, hdb_program p WHERE h.program_id = p.id AND m.program_type_id = p.program_type_id AND m.template_access_key = h.template_access_key AND m.template_type = h.template_type AND m.target_audience_scope = 'ASSIGNED' AND m.is_active = true`. Rows where no match is found are left NULL (legacy / orphan clones — they continue to function via the existing send-time read paths).

### 3.4 New enums (TS only — stored as varchar)

**`CommunicationStepEnum`** — derived from active (non-legacy) access keys:

```text
REGISTRATION_COMPLETED, REGISTRATION_CANCELLED, REGISTRATION_REFUND,
BLESSED, BLESSED_NO_PAYMENT,
HOLD, RM_HOLD,
INVOICE, PAYMENT_ACKNOWLEDGEMENT_OFFLINE, PAYMENT_PENDING_REMINDER,
BILLING_DETAILS_EDIT, BILLING_DETAILS_EDIT_REMOVED, EINVOICE_ERROR,
SWAP_DEMAND, SWAP_ACCEPT, SWAP_ACCEPT_NO_PAYMENT,
TRAVEL_PLAN_NEW, TRAVEL_PLAN_CHANGE, TRAVEL_PLAN_ONWARD_CHANGE, TRAVEL_PLAN_RETURN_CHANGE,
CO_ORDINATOR_TRAVEL_PLAN_CHANGE, CO_ORDINATOR_NEW_TRAVEL_PLAN_MADE,
CO_ORDINATOR_ONWARD_TRAVEL_PLAN_CHANGE, CO_ORDINATOR_RETURN_TRAVEL_PLAN_CHANGE,
PREFERENCE_EDITED, ADMIN_MESSAGE_NOTIFICATION,
GENERIC, OTP,
FIRST_TIMER, CHECKLIST, PREPARATORY
```

**Target role values** — reuse existing [`ROLE_KEYS`](../../src/common/constants/strings-constants.ts#L37-L47). Stored as the role-key string in `target_role`. No new enum.

`COMMUNICATION_TARGET_ROLES` whitelist constant (added next to `ROLE_KEYS`):

```ts
export const COMMUNICATION_TARGET_ROLES = [
  'Seeker',
  ROLE_KEYS.RM,
  ROLE_KEYS.RELATIONAL_MANAGER,
  ROLE_KEYS.FINANCE_MANAGER,
  ROLE_KEYS.SHOBA,
  ROLE_KEYS.ADMIN,
  ROLE_KEYS.MAHATRIA,
] as const;
```

**`CommunicationAudienceScopeEnum`** — `ASSIGNED`, `ALL`.

Role × Scope matrix (role values are existing role keys):

| `target_role` | Label | `ASSIGNED` | `ALL` |
| --- | --- | --- | --- |
| `Seeker` | Seeker | ✓ | ✗ |
| `ROLE_RM` | Relational Manager | ✓ | ✓ |
| `ROLE_FINANCE_MANAGER` | Finance Manager | ✓ | ✓ |
| `ROLE_SHOBA` | Coordinator | ✗ | ✓ |
| `ROLE_ADMIN` | Admin | ✗ | ✓ |
| `ROLE_MAHATRIA` | Mahatria | ✗ | ✓ |

`ROLE_RM` and `ROLE_RELATIONAL_MANAGER` resolve to the same DB value `relational_manager`. Prefer `ROLE_RM` as canonical write value; treat `ROLE_RELATIONAL_MANAGER` as alias on read. Invalid Role × Scope combinations are rejected at save (master-side, §4.7).

---

## 4. API Endpoints

Admin role required (reuse existing guard). Save happens via the existing Add Program controller — no per-toggle endpoints. Reads use two scoped endpoints depending on whether the program exists yet.

### 4.1 Read endpoints (Add Program section reads from these)

> **Routing convention:** this backend has **no global version prefix**. Instead, newer
> controllers opt into a `v1/` segment in their own controller path (e.g.
> `@Controller('v1/program-templates')`, `@Controller('v1/master-questions')`), while older
> controllers are bare (`program`, `program-type`, `communication`). The two **new** read
> endpoints follow the new-controller convention and are mounted under `v1/`. The **save**
> path rides on the pre-existing `program` (singular) controller, which is bare. This matches
> the FE's `urlConstants` (`v1/communication-config/...`, `v1/programs/:id/...`, and
> `program` for create/update).

| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/v1/communication-config/by-program-type/:programTypeId` | **Preview for new programs.** Returns steps + master templates for the type, with `isEnabled = master.is_default`. No `programId` required. |
| `GET` | `/v1/programs/:programId/communication-config` | **Existing-program state.** Returns steps + cloned templates for the program, with `isEnabled = clone.is_enabled`. |

Both endpoints return the same response shape (§4.2) so the FE renders them with the same components. Empty steps (no clones / no masters) are filtered out server-side.

### 4.2 Save — extend existing Add Program endpoints

No new save endpoints. The existing program create + update endpoints accept an optional `communicationConfig` field in their body:

```ts
// POST /program        (create)
// PUT  /program/:id    (update)
{
  // ...all existing program fields (name, programTypeId, programTemplateId, dates, ...)...
  communicationConfig?: {
    enabledMasterTemplateIds: number[];
  }
}
```

**Create semantics:**

- If `communicationConfig.enabledMasterTemplateIds` is present, BE creates the program, runs auto-clone for every active master row matching the program's `program_type_id`, and sets `is_enabled = (master.id IN enabledMasterTemplateIds)` on each new clone.
- If `communicationConfig` is absent, BE falls back to `is_enabled = master.is_default` (existing default behaviour).

**Update semantics:**

- BE finds all clone rows for `program_id` and sets `is_enabled = (master_template_id IN enabledMasterTemplateIds)` on each.
- If `communicationConfig` is absent from the update payload, leave existing clone states untouched.
- Single transaction with the rest of the program update.

**Validation:**

- Every id in `enabledMasterTemplateIds` must reference an active master row with `program_type_id` matching the program's `program_type_id`. Mismatch → 400.

### 4.3 Read payload (both endpoints return this shape)

```ts
// GET /v1/communication-config/by-program-type/5
// GET /v1/programs/124/communication-config

→ {
    steps: [
      {
        value: "BLESSED",
        label: "Blessed",
        templates: [
          {
            masterTemplateId: 17,
            templateAccessKey: "BLESSED_EMAIL_SEEKER",
            templateName: "Blessed - Seeker (Email)",
            channel: "EMAIL",
            targetRole: "Seeker",
            targetAudienceScope: "ASSIGNED",
            templateId: "zepto-prod-blessed-seeker",     // for tooltip / debug; not editable
            sandboxTemplateId: "zepto-sand-blessed-seeker",
            isEnabled: true
          },
          {
            masterTemplateId: 18,
            templateAccessKey: "BLESSED_WATI_SEEKER",
            templateName: "Blessed - Seeker (WhatsApp)",
            channel: "WHATSAPP",
            targetRole: "Seeker",
            targetAudienceScope: "ASSIGNED",
            templateId: "wati-tpl-blessed-seeker",
            sandboxTemplateId: null,
            isEnabled: false
          }
        ]
      },
      {
        value: "REGISTRATION_COMPLETED",
        label: "Registration Completed",
        templates: [ ... ]
      }
    ]
  }
```

- `masterTemplateId` is the stable identifier the FE sends back in `communicationConfig.enabledMasterTemplateIds` on save.
- For `/by-program-type/:programTypeId`: `isEnabled` comes from `master.is_default`.
- For `/v1/programs/:programId/communication-config`: `isEnabled` comes from `clone.is_enabled`.
- Empty steps are filtered server-side. Templates within a step sorted by `templateAccessKey ASC`.

### 4.4 DTO — added to the Add Program request body

```ts
// Existing program create / update body extended with:
{
  // ...existing program fields...,
  communicationConfig?: {
    enabledMasterTemplateIds: number[];   // master IDs the admin has ticked
  };
}
```

`enabledMasterTemplateIds` is the **full enabled set**, not a diff. Empty array means "disable everything for this program".

### 4.5 Validation (service layer)

- Every id in `enabledMasterTemplateIds` must reference an **active** `communication_templates_master` row with `program_type_id` matching the program's `program_type_id`. Mismatch → 400 with the offending ids.
- Duplicate ids in the array → silently deduped server-side (no error).
- Empty array allowed (zero communications for this program).
- `communicationConfig` itself is optional — if omitted on create, fall back to `master.is_default`; if omitted on update, leave existing clones untouched.

---

## 5. Send-time behaviour

`CommunicationService` resolves the templates to fire for a given `(programId, step)`:

1. Fetch `hdb_communication_templates` rows matching `(program_id, step)` AND `is_enabled = true`.
2. For each surviving row, resolve recipients per the Role × Scope matrix (see §5.1).
3. Dispatch using the row's `template_id` / `sandbox_template_id` + merge fields (current behaviour — uses `merge_info_answer_location_map` rows that were populated at clone time).
4. If no rows survive the filter, log + skip; do not error the surrounding business operation.

### 5.2 Auto-clone on program creation — admin-selection-aware

The existing `cloneMultipleMasterTemplatesToProgramByProgramType` flow takes an optional `enabledMasterTemplateIds` parameter:

```ts
cloneMultipleMasterTemplatesToProgramByProgramType(
  programId: number,
  programTypeId: number,
  enabledMasterTemplateIds?: number[],   // NEW — admin's selections from the form submit
  ...
)
```

For each active master row `M` matching `program_type_id`:

```text
is_enabled = enabledMasterTemplateIds !== undefined
  ? enabledMasterTemplateIds.includes(M.id)
  : M.is_default                                  // fallback for legacy callers / no FE config

master_template_id = M.id

INSERT hdb_communication_templates (
  ..., is_enabled, master_template_id,
  step, target_role, target_audience_scope, subject, body, ...
)
copying M's fields with is_enabled + master_template_id set as computed.
```

### 5.3 Add Program update — applying selections to existing clones

When `PUT /program/:id` receives a `communicationConfig.enabledMasterTemplateIds`:

```sql
UPDATE hdb_communication_templates
SET is_enabled = (master_template_id = ANY(:enabledMasterTemplateIds))
WHERE program_id = :programId
  AND master_template_id IS NOT NULL;
```

Wrapped in the same transaction as the rest of the program update. Clones with `master_template_id IS NULL` (legacy orphan clones) are untouched.

### Consumer audit (one-time)

Seven modules currently read `hdb_communication_templates` (registration, registration-approval, payment, messages, scheduler, communication controller, communication service). Each read site that selects templates for actual dispatch must include `is_enabled = true` in its filter. Read sites that show templates for admin/debug purposes (listing all templates for a program) can read both states. Audit checklist:

- [ ] `src/registration/registration.service.ts`
- [ ] `src/registration-approval/`
- [ ] `src/payment/`
- [ ] `src/messages/`
- [ ] `src/scheduler/`
- [ ] `src/communication/communication.controller.ts`
- [ ] `src/communication/communication.service.ts`

For each: confirm whether the lookup is for send-time dispatch (add `is_enabled = true`) or for admin display (no change).

### 5.1 Recipient resolution

| `target_role` | Scope | Resolver |
| --- | --- | --- |
| `Seeker` | `ASSIGNED` | registrant from `hdb_program_registration`. |
| `ROLE_RM` | `ASSIGNED` | `users` via `hdb_program_registration.rm_contact` (mirrors `getRmContactUserName` in [communication-merge-data.service.ts:1057-1089](../../src/communication/service/communication-merge-data.service.ts#L1057-L1089)). |
| `ROLE_RM` | `ALL` | `users` JOIN `user_role_maps` JOIN `roles` WHERE `role.key IN ('ROLE_RM','ROLE_RELATIONAL_MANAGER')` AND user active. Filtered to program-scope RMs if the program has an allocation list. |
| `ROLE_FINANCE_MANAGER` | `ASSIGNED` | program-config finance contact (fallback: first active `ROLE_FINANCE_MANAGER`). |
| `ROLE_FINANCE_MANAGER` | `ALL` | all active `ROLE_FINANCE_MANAGER`. |
| `ROLE_SHOBA` | `ALL` | all active `ROLE_SHOBA` (mirrors `getCoordinatorName` in [communication-merge-data.service.ts:1178-1207](../../src/communication/service/communication-merge-data.service.ts#L1178-L1207)). |
| `ROLE_ADMIN` | `ALL` | all active `ROLE_ADMIN`. |
| `ROLE_MAHATRIA` | `ALL` | all active `ROLE_MAHATRIA`. |

For `ALL`-scope dispatch, merge entries with `is_per_recipient = true` (`rm.name`, `rm.email`, `rm.mobile`, `user.phone`) re-resolve per recipient.

### 5.2 Merge-info resolution

The runtime resolver at [communication-merge-data.service.ts:39-81](../../src/communication/service/communication-merge-data.service.ts#L39-L81) walks the master `merge_field_map` JSONB directly: each entry's `sourceColumn` is dispatched against `computedFunctionRegistry` for computed fields or treated as a DB column otherwise. `merge_info_answer_location_map` is **not used** by this flow — that table belongs to the old per-program clone model and stays untouched for backwards compatibility with other features.

---

## 6. Merge-Field Catalog Seed

Seeded via migration from two sources:

- All 17 computed functions in [communication-merge-data.service.ts:61-81](../../src/communication/service/communication-merge-data.service.ts#L61-L81).
- All distinct `sourceTable` + `sourceColumn` pairs found in existing `communication_templates_master.merge_field_map` JSONB and legacy seed migration `10_sync-hdb-templates-with-master.sql`.

Initial seed entries (sample — full list ~55):

| catalog_key | source_table | source_column | data_type | format_type | description |
| --- | --- | --- | --- | --- | --- |
| `reg.full_name` | `hdb_program_registration` | `full_name` | string | — | Registrant's full name |
| `reg.mobile_number` | `hdb_program_registration` | `mobile_number` | string | — | Registration mobile |
| `reg.edit_link` | `computed` | `registrationEditLink` | string | — | Registration edit URL |
| `program.name` | `program_v1` | `name` | string | — | Program name |
| `program.starts_at` | `computed` | `getProgramField:starts_at` | date | `custom:DD-MM-YYYY` | Program start date |
| `program.ends_at` | `computed` | `getProgramField:ends_at` | date | `custom:DD-MM-YYYY` | Program end date |
| `program.base_price` | `computed` | `getProgramField:base_price` | number | `currency` | Program base price |
| `allocated.name` | `computed` | `getAllocatedProgramField:name` | string | — | Allocated program name |
| `allocated.dates_range` | `computed` | `formatProgramDateRange` | string | — | "DD-MM-YYYY to DD-MM-YYYY" |
| `payment.amount` | `computed` | `calculateTotalPaymentAmount` | number | — | Total paid amount |
| `payment.online_link` | `computed` | `generatePaymentLink` | string | — | Online payment URL |
| `payment.last_date` | `computed` | `getAllocatedProgramStartsMinus10Days` | date | `custom:DD-MM-YYYY` | Payment due date |
| `rm.name` | `computed` | `getRmContactUserName` | string | — | Relational manager name |
| `coordinator.name` | `computed` | `getCoordinatorName` | string | — | Coordinator name |
| `user.phone` | `computed` | `getUserFormattedPhone` | string | — | Country code + phone |
| `system.current_date` | `computed` | `currentDate` | date | `custom:DD-MM-YYYY` | Today |
| `system.otp` | `computed` | `generateOTP` | string | — | 6-digit OTP |

**Catalog additions for FE display fields (require new computed functions added in this rollout):**

| catalog_key | source_column | data_type | description |
| --- | --- | --- | --- |
| `seeker.first_name` | `getUserFirstName` | string | Seeker first name (via `users` JOIN) |
| `seeker.last_name` | `getUserLastName` | string | Seeker last name |
| `seeker.gender` | `getUserGender` | string | Seeker gender |
| `seeker.date_of_birth` | `getUserDOB` | date | Seeker DOB |
| `reg.email` | `email` (DB column) | string | Registration email |
| `reg.status` | `status` (DB column) | string | Registration status |
| `program.code` | `getProgramField:code` | string | Program code |
| `program.venue_name` | `getProgramField:venue_name` | string | Program venue name |
| `program.venue_address` | `getProgramField:venue_address` | string | Program venue address |
| `program.mode` | `getProgramField:mode` | string | Online / offline |
| `program.online_join_link` | `getProgramField:online_join_link` | string | Online program join URL |
| `payment.currency` | `currency` (DB column) | string | Payment currency |
| `payment.status` | `status` (DB column) | string | Payment status |
| `payment.transaction_id` | `transaction_id` (DB column) | string | Payment transaction ID |
| `payment.paid_on` | `paid_at` (DB column) | date | Payment date |
| `rm.email` | `getRmContactEmail` | string | Assigned RM email |
| `rm.mobile` | `getRmContactMobile` | string | Assigned RM mobile |
| `org.name` | `getOrgName` | string | Org name |
| `org.helpline_number` | `getOrgHelplineNumber` | string | Helpline |
| `org.support_email` | `getOrgSupportEmail` | string | Support email |
| `action.approve_link` | `generateApproveLink` | string | Approval URL |
| `action.reject_link` | `generateRejectLink` | string | Rejection URL |
| `action.view_registration_link` | `generateViewRegistrationLink` | string | View registration URL |

Set `is_per_recipient = true` on: `rm.name`, `rm.email`, `rm.mobile`, `user.phone`.

The migration also reads existing `merge_field_map` JSONB across all masters and adds any `keyName` not yet in the catalog (auto-derived `catalog_key = <source_table>.<source_column>`).

---

## 7. Frontend Flow Summary

See the dedicated [UI Spec](./COMMUNICATION_TEMPLATE_CONFIG_UI_SPEC.md) for full FE design (component breakdown, state management, loading/error/empty states, accessibility, tests).

Backend-relevant summary:

- **One accordion per step** on Add Program. Each accordion contains a multi-select dropdown of templates that fire on that step + a list of currently-selected template names below the dropdown.
- **Read endpoint depends on mode:** new programs use `GET /v1/communication-config/by-program-type/:programTypeId`; existing programs use `GET /v1/programs/:programId/communication-config`. Both return the same response shape.
- **Save lives on the Add Program form submit** — there are no separate save endpoints. The FE includes `communicationConfig.enabledMasterTemplateIds` in the program create / update request body. The BE applies the selections in the same transaction as the rest of the program save.

What the BE must guarantee to make the UI work cleanly:

- Both read endpoints filter out empty steps server-side.
- Both read endpoints include `masterTemplateId` on every template row — this is the stable id the FE returns in `communicationConfig`.
- Update validates that every id in `enabledMasterTemplateIds` matches the program's `program_type_id`; mismatch → 400.
- Save is atomic with the rest of the program update (one transaction).

Notes:

- "Steps" = `CommunicationStepEnum`, not `RegistrationStatusEnum`.

---

## 8. Out of Scope

- Per-program editing of `template_id`, `sandbox_id`, or merge-info (master-only).
- Migrating legacy access keys (`BLESSED`, `HOLD`, `INVOICE`, `SWAP_DEMAND`, `PREFERENCE_EDITED`, `ADMIN_MESSAGE_NOTIFICATION`, `BILLING_DETAILS_REMOVED`, `EINVOICE_ERROR`) to the new naming scheme — left as-is, hidden from new UI.
- WYSIWYG body/subject editing — `subject` / `body` columns store plain text in v1.
- i18n for `subject` / `body`.
- SMS / Push surfaces on Add Program (SMS remains supported at master level).
- Soft delete / history on exclusion rows.

---

## 9. Rollout

1. **Migration**
   - Add `step`, `target_role`, `target_audience_scope`, `subject`, `body`, `is_default` to `communication_templates_master`. `is_default` defaults to `true` so existing rows behave as before.
   - Add `is_enabled`, `master_template_id`, `step`, `target_role`, `target_audience_scope` to `hdb_communication_templates`. `is_enabled` defaults to `true`.
   - Drop any existing uniqueness on `(program_type_id, template_access_key, template_type)` that omits `target_audience_scope`; rebuild as `uq_ctm_pt_ak_type_scope`.
   - Create `merge_field_catalog` table + seed.
   - Back-fill `step` / `target_role` on both `communication_templates_master` and `hdb_communication_templates` by parsing `template_access_key`; `target_audience_scope` defaults to `ASSIGNED` on both.
   - Back-fill `master_template_id` on `hdb_communication_templates` via the join query in §3.3.
   - Curation pass (optional, can be deferred): flip `is_default` to `false` on the master rows the team decides shouldn't auto-enable on new programs. Plain SQL migration after launch.

2. **Backend code**
   - New TS enums: `CommunicationStepEnum`, `CommunicationAudienceScopeEnum`. Add `COMMUNICATION_TARGET_ROLES` constant next to `ROLE_KEYS`.
   - New entity: `MergeFieldCatalog`.
   - Update `CommunicationTemplates` entity to add `isEnabled`, `masterTemplateId`, `step`, `targetRole`, `targetAudienceScope` properties.
   - Update `CommunicationTemplatesMaster` entity to add `step`, `targetRole`, `targetAudienceScope`, `subject`, `body`, `isDefault`.
   - Extend `CommunicationTemplatesRepository` with: `findByProgramGrouped(programId)` (returns nested steps→templates structure per §4.3), `applyEnabledSet(programId, enabledMasterTemplateIds[], userId)` (the `UPDATE ... SET is_enabled = ANY(...)` operation from §5.3).
   - New service: `CommunicationConfigReadService` (handles both read endpoints — by program-type and by program).
   - New controller: `CommunicationConfigController` exposing the two GETs from §4.1.
   - Extend the existing Add Program controller (location TBD via grep; likely `src/program/`) to accept the `communicationConfig` field in create + update request bodies. On create → pass `enabledMasterTemplateIds` to `cloneMultipleMasterTemplatesToProgramByProgramType`. On update → call `applyEnabledSet`.
   - Update `cloneMasterTemplateToProgram` and `cloneMultipleMasterTemplatesToProgramByProgramType` to accept optional `enabledMasterTemplateIds`, set `master_template_id` on each new clone, and compute `is_enabled` per §5.2.
   - Update `CommunicationService` send-time lookups to add `is_enabled = true` to the filter. Run the §5 consumer audit and patch other read sites as needed.

3. **Tests** (Jest + Supertest per [.claude/rules/backend/testing.md](../../.claude/rules/backend/testing.md))
   - Read by program type: returns steps + master templates with `isEnabled = master.is_default`; empty steps filtered out.
   - Read by program: returns steps + clones with `isEnabled = clone.is_enabled`; empty steps filtered out.
   - Both reads return the same response shape (nested steps→templates with `masterTemplateId` on every row).
   - Create program with `communicationConfig`: clones get `is_enabled = (master.id IN enabledMasterTemplateIds)`. `master_template_id` is populated on every clone.
   - Create program without `communicationConfig`: falls back to `is_enabled = master.is_default`.
   - Update program with `communicationConfig`: all existing clones for the program have `is_enabled` updated to match. Clones with `master_template_id IS NULL` are untouched.
   - Update program without `communicationConfig`: existing clones are untouched.
   - Validation: any id in `enabledMasterTemplateIds` whose `program_type_id` doesn't match the program's → 400 with the offending ids.
   - Send-time: rows with `is_enabled = true` fire; `is_enabled = false` rows do not; zero enabled rows for a step = log + skip.
   - `is_per_recipient` entries re-resolve per recipient on `ALL`-scope dispatch.
   - Defaults isolation: flipping `is_default` on a master row does NOT retroactively change existing programs' clones.

---

## 10. Files Touched (estimated)

```text
db/migrations/
  YYYYMMDD_add-master-config-columns.sql              (new)
  YYYYMMDD_add-clone-columns.sql                      (new — is_enabled, master_template_id, step/role/scope)
  YYYYMMDD_create-merge-field-catalog.sql             (new)
  YYYYMMDD_seed-merge-field-catalog.sql               (new)
  YYYYMMDD_backfill-step-role-master-id.sql           (new — back-fills step/role + master_template_id)

src/common/entities/
  communication-templates-master.entity.ts            (add step, target_role, target_audience_scope, subject, body, is_default)
  communication-templates.entity.ts                   (add is_enabled, master_template_id, step, target_role, target_audience_scope)
  merge-field-catalog.entity.ts                       (new)

src/common/enum/
  communication-step.enum.ts                          (new)
  communication-audience-scope.enum.ts                (new)

src/common/constants/
  strings-constants.ts                                (add COMMUNICATION_TARGET_ROLES whitelist)

src/communication/
  config/
    communication-config.controller.ts                (new — §4.1 reads)
    service/
      communication-config-read.service.ts            (new — handles both read endpoints)
  repositories/
    communication-templates.repository.ts             (extend: findByProgramGrouped, applyEnabledSet)
  service/
    communication-templates-master.service.ts         (clone copies new columns; accepts optional enabledMasterTemplateIds)
  communication.service.ts                            (send-time filter on is_enabled=true)
  communication.module.ts                             (register new providers)

src/program/   (or wherever Add Program controller + service live — find via grep)
  program.controller.ts                               (extend create + update DTOs to accept communicationConfig)
  program.service.ts                                  (pass enabledMasterTemplateIds to clone on create; call applyEnabledSet on update)
  dto/
    create-program.dto.ts                             (extend with optional communicationConfig)
    update-program.dto.ts                             (extend with optional communicationConfig)

+ audit other 6 consumer modules per §5 consumer audit checklist
```
