# Parental Consent Form — Backend Spec
**Feature ID:** PCF-001  
**Status:** Draft  
**Date:** 2026-06-11  

---

## 1. Overview

A parental consent form is required for seekers whose age at registration falls within the consent range configured on the programme. Seekers below the minimum age are blocked from registering at the DOB form-field level. Seekers above the maximum consent age are unaffected.

The workflow is triggered automatically after payment completion. 
The payment email (online confirmation or offline acknowledgement) and a dedicated parental consent email.
The seeker receives a pre-filled consent form PDF, gets it signed offline, and uploads the signed copy back through the portal. Admins can also trigger, upload, approve, and delete consent records from the dashboard.

---

## 2. Age Eligibility

| Age at Registration | Registration Allowed | Consent Form Required |
|---|---|---|
| Below 16 | No — DOB field enforces minimum age 16 at form level | Not applicable |
| 16 to `childMaxAge` (inclusive) | Yes | Yes — mandatory |
| Above `childMaxAge` | Yes | No |

**Blocking rule (age < 16):** Enforced inside `RegistrationAnswerValidationService` when validating the DOB answer by binding key `dob`. The DOB `Question.config` JSONB carries `minAge: 16`. The service calculates age via `getAgeFromDOB()` and returns a validation error if `age < config.minAge`. One check, driven by the question's binding key config — no separate backend block elsewhere.

**Consent rule (age ≤ childMaxAge):** After the registration is saved and payment completes, backend checks `age <= program.childMaxAge`. If true, the parental consent flow triggers. `childMaxAge` defaults to 17 (existing `child_max_age` column on `program_v1`).

Age is determined from `dob` on `hdb_program_registration` at the time of the relevant action (payment, DOB correction, manual send).

---

## 3. Database Changes

### 3.1 `hdb_program_registration` — new columns

| Column | Type | Nullable | Default | Purpose |
| --- | --- | --- | --- | --- |
| `parental_form_uploaded_at` | timestamptz | yes | null | Timestamp of the most recent signed-form upload |
| `parental_form_uploaded_by` | bigint | yes | null | User ID of the uploader (seeker or admin) |
| `parental_form_updated_at` | timestamptz | yes | null | Timestamp of the most recent update to any parental form field |

**Existing columns already present (no change needed):**
- `parental_form_pdf_url` — S3 URL of the pre-filled blank PDF sent to the seeker
- `parental_form_status` — enum, extended (see Section 4, made nullable)

### 3.2 `program_v1` — new columns

| Column | Type | Nullable | Default | Purpose |
| --- | --- | --- | --- | --- |
| `parental_consent_enabled` | boolean | no | false | Master on/off switch for this programme |
| `parental_consent_form_as_attachment` | boolean | no | true | `true` = PDF attached to email; `false` = PDF sent as a separate second email |
| `parental_consent_upload_deadline` | timestamptz | yes | null | Programme-wide upload cutoff; shown in email and on upload screen. null = no limit |
| `parental_consent_form_content` | text | yes | null | The consent form template stored directly on the programme as Markdown or HTML. Used by Puppeteer to render the pre-filled PDF. `null` = feature inactive for this programme even if `parental_consent_enabled = true` |

> **How these get set:** When a new programme is created, these values are cloned from the parent programme template — the same way `shouldSendTerms` and other inherited flags work. They are not independently configured from scratch per programme.
>
> **No form selection step:** The consent form is stored directly on the programme record — there is no separate form entity to select or link. Admins paste/upload the MD or HTML content into the programme settings. The content supports template variables: `{{seekerName}}`, `{{programmeName}}`, `{{date}}`. These are interpolated at PDF generation time by `ParentalConsentPdfService`.

**Existing column used (no change needed):** `child_max_age` (`childMaxAge`) — already on `program_v1`. The consent check uses `age <= program.childMaxAge`. No separate `parental_consent_child_max_age` column is added.

---

## 4. Enum Changes

### 4.1 `ParentalFormStatusEnum` — add new values only

Existing values `PENDING`, `COMPLETED`, `NOT_APPLICABLE` are kept unchanged. Add:

```ts
PENDING_UPLOAD = 'pending_upload' // triggered; signed form not yet uploaded
SUBMITTED      = 'submitted'      // signed form uploaded by seeker or admin
VERIFIED       = 'verified'       // admin verified and approved
```

`NOT_APPLICABLE` (existing) is used for seekers above `childMaxAge` — no new value is added.

### 4.2 `CommunicationTemplateAccessKeyEnum` — add new keys

| Key | When used |
| --- | --- |
| `PARENTAL_CONSENT_EMAIL_SEEKER` | Used for both auto-send post-payment and admin manual send |

---

## 5. Status Transition Map

```
Registration saved (age > childMaxAge)   → NOT_APPLICABLE
Registration saved (16 – childMaxAge)    → null (no status until payment done)
Registration attempt (below 16)                         → BLOCKED — no record created

Payment confirmed (16 – childMaxAge)     → PENDING_UPLOAD
  └─ parentalFormPdfUrl set (pre-filled PDF)
  └─ consent email sent

Seeker uploads signed form                              → SUBMITTED
  └─ parentalFormUploadedAt set
  └─ parentalFormUploadedBy = null (seeker upload)

Admin uploads on behalf of seeker                       → SUBMITTED → VERIFIED (immediate)
  └─ parentalFormUploadedAt set
  └─ parentalFormUploadedBy = admin user ID

Admin approves seeker upload                            → VERIFIED

Admin deletes consent record                            → PENDING_UPLOAD
  └─ parentalFormUploadedAt = null
  └─ parentalFormUploadedBy = null

Admin manually sends email                              → PENDING_UPLOAD
Admin corrects DOB → age now in consent range           → PENDING_UPLOAD (only if payment done)
  └─ new pre-filled PDF generated and sent
```

---

## 6. Existing Integration — `personType` and `getParentalFormStatus`

### `personType` on `hdb_program_registration`

The registration entity already has a `personType: PersonTypeEnum` column (`CHILD`, `ADULT`, `ELDER`). It is set in `registration.repository.ts` whenever DOB changes:

```ts
// registration.repository.ts (existing — around line 5564)
if (isRegistrationTable && entityData.dob && programId) {
  const personType = await this.resolvePersonType(manager, new Date(entityData.dob), programId);
  if (personType) {
    entityData.personType = personType;
    entityData.parentalFormStatus = getParentalFormStatus(personType, { parentalFormStatus: registration.parentalFormStatus });
  }
}
```

`resolvePersonType` uses `program.childMaxAge` and `program.elderMinAge` — `CHILD` if `age < childMaxAge`, `ELDER` if `age >= elderMinAge`, otherwise `ADULT`.

### `getParentalFormStatus` — must be updated

Current behaviour (`common.util.ts`):

- `CHILD` → `PENDING` (or `COMPLETED` if already completed)
- `ADULT` / `ELDER` → `NOT_APPLICABLE`

**Required changes for PCF-001:**

1. Accept `parentalConsentEnabled: boolean` as part of the config param.
2. If `parentalConsentEnabled = false`: always return `NOT_APPLICABLE`, regardless of person type.
3. Map `CHILD` + enabled → `PENDING_UPLOAD` (not `PENDING`).
4. Preserve in-progress statuses — if current status is already `SUBMITTED` or `VERIFIED`, keep it (do not regress on DOB re-entry).

Updated signature:

```ts
getParentalFormStatus(
  personType: PersonTypeEnum,
  currentRegistration?: { parentalFormStatus?: ParentalFormStatusEnum },
  program?: { parentalConsentEnabled?: boolean },
): ParentalFormStatusEnum
```

### Guard to add in `registration.repository.ts`

The `|| parentalConsentEnabled` check belongs inside the existing block — pass `program` through to `getParentalFormStatus` so the function handles the flag internally:

```ts
entityData.parentalFormStatus = getParentalFormStatus(
  personType,
  { parentalFormStatus: registration.parentalFormStatus },
  { parentalConsentEnabled: program.parentalConsentEnabled },
);
```

No separate `if (program.parentalConsentEnabled)` branch needed — the function returns `NOT_APPLICABLE` when disabled.

---

## 7. Utilities — `src/common/utils/common.util.ts`

### `calculateAge(dob, asOf?)`
Returns age in whole years as of `asOf` (defaults to today). Returns `null` if `dob` is falsy.

### `isParentalConsentRequired(dob, program)`
Returns `true` if `age >= 16 && age <= program.childMaxAge`.

---

## 7. Migration Script

**File:** `database/migrations/2026-06-11-01-parental-consent-form.sql`

This migration must be run before any application code for this feature is deployed. It is safe to re-run (all statements use `IF NOT EXISTS` or `IF VALUE NOT IN`).

```sql
-- ================================================
-- Migration: PCF-001 — Parental Consent Form
-- Date: 2026-06-11
-- ================================================

-- ------------------------------------------------
-- PART 1: Extend parental_form_status enum
-- PostgreSQL requires each ADD VALUE in its own statement.
-- ------------------------------------------------

DO $$ BEGIN
  IF NOT EXISTS (
    SELECT 1 FROM pg_enum e
    JOIN pg_type t ON t.oid = e.enumtypid
    WHERE t.typname LIKE '%parental_form_status%'
      AND e.enumlabel = 'pending_upload'
  ) THEN
    ALTER TYPE hdb_program_registration_parental_form_status_enum ADD VALUE 'pending_upload';
  END IF;
END $$;

DO $$ BEGIN
  IF NOT EXISTS (
    SELECT 1 FROM pg_enum e
    JOIN pg_type t ON t.oid = e.enumtypid
    WHERE t.typname LIKE '%parental_form_status%'
      AND e.enumlabel = 'submitted'
  ) THEN
    ALTER TYPE hdb_program_registration_parental_form_status_enum ADD VALUE 'submitted';
  END IF;
END $$;

DO $$ BEGIN
  IF NOT EXISTS (
    SELECT 1 FROM pg_enum e
    JOIN pg_type t ON t.oid = e.enumtypid
    WHERE t.typname LIKE '%parental_form_status%'
      AND e.enumlabel = 'verified'
  ) THEN
    ALTER TYPE hdb_program_registration_parental_form_status_enum ADD VALUE 'verified';
  END IF;
END $$;

-- ------------------------------------------------
-- PART 2: hdb_program_registration — all parental form columns
-- Existing columns included with IF NOT EXISTS so the full set is guaranteed.
-- ------------------------------------------------

ALTER TABLE hdb_program_registration
  ADD COLUMN IF NOT EXISTS parental_form_pdf_url      TEXT,
  ADD COLUMN IF NOT EXISTS parental_form_status       hdb_program_registration_parental_form_status_enum,
  ADD COLUMN IF NOT EXISTS parental_form_uploaded_at  TIMESTAMPTZ,
  ADD COLUMN IF NOT EXISTS parental_form_uploaded_by  BIGINT,
  ADD COLUMN IF NOT EXISTS parental_form_updated_at   TIMESTAMPTZ;

-- ------------------------------------------------
-- PART 3: program_v1 — new columns
-- ------------------------------------------------

ALTER TABLE program_v1
  ADD COLUMN IF NOT EXISTS parental_consent_enabled            BOOLEAN NOT NULL DEFAULT FALSE,
  ADD COLUMN IF NOT EXISTS parental_consent_form_as_attachment BOOLEAN NOT NULL DEFAULT TRUE,
  ADD COLUMN IF NOT EXISTS parental_consent_upload_deadline    TIMESTAMPTZ,
  ADD COLUMN IF NOT EXISTS parental_consent_form_content       TEXT;

-- ------------------------------------------------
-- Verification — expected: 9 rows (5 on hdb_program_registration + 4 on program_v1)
-- Includes pre-existing parental form columns to confirm full set is present.
-- ------------------------------------------------

SELECT
  table_name,
  column_name,
  data_type,
  is_nullable,
  column_default
FROM information_schema.columns
WHERE (table_name = 'hdb_program_registration'
       AND column_name IN (
         'parental_form_pdf_url',
         'parental_form_status',
         'parental_form_uploaded_at',
         'parental_form_uploaded_by',
         'parental_form_updated_at'
       ))
   OR (table_name = 'program_v1'
       AND column_name IN (
         'parental_consent_enabled',
         'parental_consent_form_as_attachment',
         'parental_consent_upload_deadline',
         'parental_consent_form_content'
       ))
ORDER BY table_name, column_name;

-- Expected: 9 rows (5 on hdb_program_registration + 4 on program_v1)
```

> **Note on enum type name:** TypeORM auto-generates the PostgreSQL enum type name from the table and column name. Confirm the exact name before running by executing:
> `SELECT typname FROM pg_type WHERE typname LIKE '%parental_form_status%';`
> If the name differs, replace `hdb_program_registration_parental_form_status_enum` accordingly.

---

## 8. Feature Steps

### Step 1 — Email trigger after payment *(Priority)*

**Files:** `database/migrations/2026-06-11-01-parental-consent-form.sql`, `payment.service.ts`, `common.util.ts`, `communication-template-access-key.enum.ts`, `parental-form-status.enum.ts`, `program.entity.ts`, `program-registration.entity.ts`

**Logic (`handlePaymentConfirmation`):**
1. Check `program.parentalConsentEnabled`. If false, skip.
2. Check `program.parentalConsentFormContent`. If null, skip (no form configured on this programme).
3. Check age using `isParentalConsentRequired(registration.dob, program)`.
4. If required:
   - Set `parentalFormStatus = PENDING_UPLOAD`
   - Generate pre-filled PDF (seeker name + programme name) via Puppeteer service; store in S3 → set `parentalFormPdfUrl`
   - Fetch template `PARENTAL_CONSENT_EMAIL_SEEKER` from `communication_templates`
   - If `program.parentalConsentFormAsAttachment = true`: attach PDF to the consent email
   - If `program.parentalConsentFormAsAttachment = false`: send PDF as a separate second email
   - Send via email queue if enabled, else directly via `communicationService`
5. Fires for both online payment capture and offline payment acknowledgement.

---

### Step 2 — Seeker upload endpoint

**Endpoint:** `POST /registrations/:id/parental-consent/upload`  
**Auth:** Seeker  
**Accepts:** `multipart/form-data`, PDF only, max 10 MB

**Validations:**
- Registration must belong to the authenticated seeker
- `parentalFormStatus` must not be `NOT_APPLICABLE`
- `program.parentalConsentUploadDeadline` — reject with 400 if current time is past deadline
- File: PDF only, max 10 MB

**On success:**
- Upload file to S3
- Set `parentalFormPdfUrl` = S3 URL of the signed file
- Set `parentalFormStatus = SUBMITTED`
- Set `parentalFormUploadedAt` = now
- Set `parentalFormUploadedBy = null` (seeker upload)
- Return on-screen success message (no email sent)

**Response to seeker includes:**  
- Success message  
- Note to contact RM if wrong file was uploaded  
- Upload deadline (`program.parentalConsentUploadDeadline`) if set

---

### Step 3 — Admin actions

#### 3a. Manual send
**Endpoint:** `POST /admin/registrations/:id/parental-consent/send`  
**Auth:** Admin  
**Body:** `{ reason?: string }` (optional, captured in audit trail)

- Seeker must be in consent age range and payment must be completed
- Generates new pre-filled PDF, sends `PARENTAL_CONSENT_EMAIL_SEEKER`
- Sets `parentalFormStatus = PENDING_UPLOAD`
- Logs to audit trail: admin user ID, seeker ID, timestamp, reason (if provided)

#### 3b. DOB correction trigger
**Where:** Existing basic-details update handler in registration service

- After saving corrected DOB, check if new age is in consent range AND payment is completed
- If yes, run the same flow as Step 1 (generate PDF, send email, set `PENDING_UPLOAD`)
- Logs to audit trail: admin user ID, old DOB, new DOB, seeker ID, timestamp

#### 3c. Admin upload on behalf of seeker
**Endpoint:** `POST /admin/registrations/:id/parental-consent/upload`  
**Auth:** Admin  
**Accepts:** `multipart/form-data`, PDF only, max 10 MB  
**Body:** `{ sourceNote?: string }` (optional, e.g. "Received via email")

- Upload file to S3
- Set `parentalFormPdfUrl` = S3 URL of the signed file
- Set `parentalFormStatus = SUBMITTED` then immediately `VERIFIED`
- Set `parentalFormUploadedAt` = now
- Set `parentalFormUploadedBy` = admin user ID
- Logs to audit trail: admin user ID, seeker ID, file name, source note (if provided), timestamp

#### 3d. Verify

**Endpoint:** `PATCH /admin/registrations/:id/parental-consent/verify`  
**Auth:** Admin

- Set `parentalFormStatus = VERIFIED`
- Logs to audit trail: admin user ID, seeker ID, timestamp

#### 3e. Delete consent record
**Endpoint:** `DELETE /admin/registrations/:id/parental-consent`  
**Auth:** Admin  
**UI:** Confirmation modal required before calling

- Permanently remove the signed file from S3
- Set `parentalFormPdfUrl = null`
- Set `parentalFormUploadedAt = null`
- Set `parentalFormUploadedBy = null`
- Set `parentalFormStatus = PENDING_UPLOAD`
- No re-notification email sent (handled offline by admin/RM)
- Logs to audit trail: admin user ID, seeker ID, timestamp

---

### Step 4 — Admin consent form list

**Endpoint:** `GET /admin/parental-consents`  
**Auth:** Admin  
**Pagination:** limit + offset

**Filters:**
- `status` (enum)
- `dateFrom` / `dateTo` (triggered date range)
- `minAge` / `maxAge`

**Response columns per record:**
- Seeker name
- Registration ID
- Seeker age
- Consent status
- Date triggered
- Date of last upload
- Uploaded by (seeker or admin username)
- Link to open consent record detail

**Detail view** (`GET /admin/parental-consents/:registrationId`):
- All list columns
- View or download the uploaded signed PDF
- Audit trail entries for this record

---

### Step 5 — PDF generation

**Where:** New `ParentalConsentPdfService` in `src/parental-consent/`

- Uses existing Puppeteer / browser-manager service
- Source: `program.parentalConsentFormContent` — the MD or HTML stored directly on the programme. If this field is `null`, PDF generation is skipped and the flow does not trigger (treat as disabled).
- Template variables interpolated at render time: `{{seekerName}}`, `{{programmeName}}`, `{{date}}`
- MD content is converted to HTML before passing to Puppeteer; HTML content is used as-is
- Output: PDF buffer → uploaded to S3 → URL returned
- Called from Step 1 and Step 3a/3b before sending the email
- **No form-selection UI:** there is no dropdown or link to a separate form record. The form content lives on the programme entity and is edited in programme settings.

---

## 9. Audit Trail

All events are immutable. No user can edit or delete them.

| Event | Triggered By | Fields Logged |
|---|---|---|
| Auto-trigger (post-payment) | System | seeker ID, timestamp |
| DOB correction trigger | Admin | admin ID, old DOB, new DOB, seeker ID, timestamp |
| Manual email send | Admin | admin ID, seeker ID, timestamp, reason (if provided) |
| Seeker upload | Seeker | seeker ID, file name, file size, timestamp |
| Admin upload | Admin | admin ID, seeker ID, file name, source note (if provided), timestamp |
| Verify | Admin | admin ID, seeker ID, timestamp |
| Delete | Admin | admin ID, seeker ID, timestamp |

Audit logs are accessible to admin roles from within the consent form record detail view.

---

## 10. Validations Summary

| Rule | Where enforced |
|---|---|
| Age < `question.config.minAge` (16) → validation error on DOB answer | `RegistrationAnswerValidationService` — DOB binding key check |
| Consent flow only fires if `parentalConsentEnabled = true` | Payment confirmation, admin send |
| Consent required if `age <= program.childMaxAge` | Payment confirmation, admin send, DOB patch |
| Upload only allowed if `parentalFormStatus != NOT_APPLICABLE` | Seeker upload endpoint |
| Upload rejected after `parentalConsentUploadDeadline` | Seeker upload endpoint |
| File must be PDF, max 10 MB | Seeker and admin upload endpoints |
| Manual send only for seekers in consent age range with payment done | Admin manual send endpoint |
| DOB correction trigger only if payment done | Basic-details update handler |

---

## 11. Out of Scope

- WhatsApp or SMS for consent form delivery
- E-signature or digital signing within the platform
- Parent/guardian portal or direct parent account creation
- Automated AI-based review of uploaded form content
- Bulk consent form operations
