# Forms Module — Architecture Map

> **Last updated:** 2026-06-10
> **Status:** Living document — incrementally updated as new information is discovered

---

## 1. Purpose & Responsibilities

The Forms module is a **schema-driven dynamic form engine**. It manages:
- Form schema definitions (JSON-based, versioned)
- Dynamic form rendering from schema (supports 20+ field types)
- Form validation against schema rules (with conditional visibility)
- Form submission storage and lifecycle management
- Form number generation (sequential per form type)
- Form builder UI for editing schemas
- Print-friendly form rendering
- Bridge API for other modules to render/validate/submit forms programmatically

It does **NOT** directly manage:
- Specific form logic (e.g., membership rules — those live in consuming modules)
- Payment for form fees (consuming modules handle via Cashier)
- Workflow advancement (consuming modules decide what happens after form submission)
- File uploads within forms (file fields exist in schema but storage is basic)

---

## 2. Directory & File Structure

```
app/Modules/Forms/
├── bootstrap.php                          # Menu + permission registration + dynamic per-form permissions
├── Routes.php                             # Web routes (10 routes)
├── Controllers/
│   ├── FormController.php                # Submissions (index, render, submit, show, print, updateStatus)
│   └── FormBuilderController.php         # Schema management (index, edit, update)
├── Models/
│   ├── FormSchema.php                    # Schema model (findByCode, allActive, getSections)
│   └── FormSubmission.php               # Submission model (search, generateFormNumber, getData)
├── Services/
│   ├── FormBridge.php                   # External API for other modules (render, validate, submit)
│   ├── FormRenderer.php                 # HTML generator from schema (20+ field types, sections, repeatable)
│   └── FormValidator.php                # Validation engine (conditional visibility, rule building)
└── Views/
    ├── builder.php                       # Schema editor (JSON editing)
    ├── render.php                        # Form display/submission view
    ├── print.php                         # Print-friendly form output
    └── submissions.php                   # Submission list with filters
```

---

## 3. Database Schema (Production — Source of Truth)

### 3.1 `form_schemas` Table (10 rows as of 2026-06-10)

| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | bigint unsigned | NO | PRI | auto_increment |
| form_code | varchar(50) | NO | UNI | Unique identifier (e.g., 'NEW_MEMBERSHIP') |
| name_ar | varchar(200) | NO | | Arabic form name |
| name_en | varchar(200) | YES | | English form name |
| form_fee | decimal(15,2) | NO | | Fee for this form (default: 0.00) |
| validity_days | int unsigned | YES | | Days until submission expires (null = never) |
| schema_json | json | NO | | Full form definition (sections, fields, validation) |
| version | int unsigned | NO | | Schema version (incremented on edit, default: 1) |
| published_at | timestamp | YES | | When the schema was published |
| is_active | tinyint(1) | NO | MUL | Default: 1 |
| created_at | timestamp | NO | | |
| updated_at | timestamp | NO | | On update cascade |
| created_by | bigint unsigned | YES | | FK to employees |
| updated_by | bigint unsigned | YES | | FK to employees |

**Current active schemas (10 total):**

| form_code | Purpose |
|-----------|---------|
| NEW_MEMBERSHIP | New member application form |
| TRANSFER_SEPARATION | Transfer/separation request |
| ADDITION_CHILD | Add child to membership |
| ADDITION_SPOUSE | Add spouse to membership |
| ADDITION_TEMPORARY | Add temporary member |
| SEASONAL_MEMBERSHIP | Seasonal membership application |
| LOST_CARNET | Lost carnet replacement request |
| DATA_UPDATE | Member data update request |
| SPORTS_REGISTRATION_MEMBER | Sports registration (member) |
| SPORTS_REGISTRATION_NONMEMBER | Sports registration (non-member) |

### 3.2 `form_submissions` Table (0 rows as of 2026-06-10)

| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | bigint unsigned | NO | PRI | auto_increment |
| form_schema_id | bigint unsigned | NO | MUL | FK to form_schemas |
| schema_version | int unsigned | NO | | Version of schema used (default: 1) |
| form_number | varchar(50) | NO | UNI | Format: {PREFIX}-YYYY-NNNNN |
| submitted_data_json | json | NO | | All form field values as JSON |
| status | varchar(50) | NO | MUL | draft, submitted, under_review, approved, rejected, expired |
| submitted_by_employee_id | bigint unsigned | YES | | FK to employees |
| member_id | bigint unsigned | YES | MUL | FK to members (optional) |
| expires_at | timestamp | YES | MUL | Auto-calculated from schema.validity_days |
| fee_receipt_number | varchar(50) | YES | | Payment receipt reference |
| notes | text | YES | | |
| created_at | timestamp | NO | | |
| updated_at | timestamp | NO | | On update cascade |
| created_by | bigint unsigned | YES | | FK to employees |
| updated_by | bigint unsigned | YES | | FK to employees |

---

## 4. Routes (10 total)

| Method | Path | Handler | Permission |
|--------|------|---------|------------|
| GET | /forms | FormController@index | forms.view |
| GET | /forms/submissions | FormController@submissions | forms.view |
| GET | /forms/submissions/{id} | FormController@show | forms.view |
| POST | /forms/submissions/{id}/status | FormController@updateStatus | forms.view |
| GET | /forms/render/{code} | FormController@render | forms.view + forms.access.{code} |
| POST | /forms/submit/{code} | FormController@submit | forms.view + forms.access.{code} |
| GET | /forms/print/{id} | FormController@printForm | forms.view |
| GET | /forms/builder | FormBuilderController@index | forms.edit_schema |
| GET | /forms/builder/{id} | FormBuilderController@edit | forms.edit_schema |
| POST | /forms/builder/{id} | FormBuilderController@update | forms.edit_schema |

---

## 5. Events Dispatched

| Event | Payload | Dispatched From |
|-------|---------|-----------------|
| `form.submitted` | `{submission_id, form_code, form_number, data, ?member_id}` | FormController::submit, FormBridge::submit |

---

## 6. Events Consumed (by other modules)

**No modules currently register listeners for `form.submitted`.**

The form.submitted event is dispatched but has no listeners. Consuming modules (Members, Children, Transfers) use FormBridge::submit() directly and process the result synchronously rather than reacting to events.

---

## 7. Cross-Module Dependencies

### 7.1 Forms module IMPORTS FROM:

| Module | What it uses |
|--------|-------------|
| Core | Validator class (for field validation rules) |
| Core | App, EventBus, Logger, Pagination |
| DB Lookups | governorates, countries, qualifications, branches tables (via FormRenderer dynamic options) |

The Forms module is intentionally low-dependency — it is a generic engine.

### 7.2 Other modules that IMPORT FROM Forms:

| Module | What it imports | How |
|--------|----------------|-----|
| Members | FormBridge::submit('NEW_MEMBERSHIP', ...) | Submits filled membership form |
| Members | FormBridge::exists() | Checks if schema exists before submitting |
| Transfers | FormBridge::submit('TRANSFER_SEPARATION', ...) | Submits transfer/separation form |
| Children | FormBridge::submit('ADDITION_CHILD', ...) | Submits child addition form |
| Interviews | References form_submission_id | Links interview to the member's form submission |
| Dashboard | Queries form_submissions for expiring forms count | Dashboard stats |
| Audit | References 'form_submissions' in entity type mapping | Audit trail labeling |

### 7.3 FormBridge — The Integration API

Other modules interact with Forms exclusively through `FormBridge`:
- `FormBridge::render($code, $prefillData)` — Get HTML for a form
- `FormBridge::validate($code, $data)` — Validate data against schema
- `FormBridge::submit($code, $data, $memberId)` — Full submit flow
- `FormBridge::exists($code)` — Check if a form schema exists
- `FormBridge::getFee($code)` — Get form fee amount

---

## 8. Permissions

| Key | Description |
|-----|-------------|
| forms.view | View form submissions list |
| forms.edit_schema | Access form builder / edit schemas |
| forms.create_schema | Create new form schemas (registered but no create route) |
| forms.access.{form_code} | Per-form access control (dynamically registered from DB) |

**Dynamic permission registration**: On bootstrap, the module queries all active form_schemas and registers a `forms.access.{form_code}` permission for each. This allows per-form access control.

---

## 9. Schema Structure (JSON Format)

### 9.1 Top-Level Schema

```json
{
  "sections": [
    {
      "key": "section_key",
      "label_ar": "Arabic Section Label",
      "order": 1,
      "visible_when": { "field": "field_key", "operator": "eq", "value": "some_value" },
      "repeatable": false,
      "max_repeats": 10,
      "fields": [...]
    }
  ]
}
```

### 9.2 Field Definition

```json
{
  "key": "field_name",
  "label_ar": "Arabic Label",
  "type": "text|national_id|select|date|textarea|checkbox|radio|...",
  "required": true,
  "editable": true,
  "width": "half|full",
  "order": 1,
  "default_value": "",
  "validation": "required|string|max:200",
  "options": [{"value": "val", "label_ar": "Label"}],
  "data_source": "governorates|qualifications|branches|...",
  "visible_when": { "field": "other_field", "operator": "eq", "value": "trigger_value" },
  "auto_parse": true,
  "populates": ["field1", "field2"],
  "help_text_ar": "Helper text"
}
```

### 9.3 Supported Field Types (20+)

| Type | Description |
|------|-------------|
| text | Standard text input |
| national_id | 14-digit NID with auto-parse support |
| phone / phone_eg | Phone input (tel type) |
| email | Email input |
| passport | Passport number text |
| number | Integer input |
| decimal | Float input (step=0.01) |
| currency | Read-only formatted currency display |
| date | Date picker |
| select | Static dropdown (options array) |
| select_dynamic | DB-driven dropdown (data_source) |
| textarea | Multi-line text |
| checkbox | Boolean checkbox |
| radio | Radio button group |
| file | File upload |
| computed | Read-only computed field |
| static_text | Display-only text block |
| hidden | Hidden field |
| auto_increment | Auto-generated number (read-only) |
| signature | Acknowledgment checkbox (styled differently) |

### 9.4 Dynamic Data Sources

| Source Key | Table/Data |
|-----------|-----------|
| governorates | governorates (code, name_ar) |
| countries | countries (nationality_ar) |
| qualifications | qualifications (id, name_ar) |
| branches | branches (id, name_ar) |
| religions | Static: muslim, christian, other |
| marital_statuses | Static: single, married, divorced, widowed |
| employment_types | Static: employed, self_employed, professions, retired, other |

### 9.5 Conditional Visibility

Both sections and fields support `visible_when`:
```json
{"field": "employment_type", "operator": "eq", "value": "employed"}
```

Operators: `eq`, `neq`, `in`, `gt`, `lt`, `gte`, `lte`, `not_empty`, `empty`

Hidden sections/fields are skipped during validation.

---

## 10. Core Business Flows

### 10.1 Direct Form Submission (via UI)

```
1. GET /forms/render/{code} — Render blank form
   - Find schema by form_code
   - Check forms.access.{code} permission if registered
   - Render HTML via FormRenderer
2. POST /forms/submit/{code} — Submit form
   - Find schema, check permission
   - Validate submitted data against schema (FormValidator)
   - If errors: re-render form with errors
   - Generate form_number ({PREFIX}-YYYY-NNNNN)
   - Calculate expires_at from schema.validity_days
   - Create FormSubmission record
   - Dispatch form.submitted event
```

### 10.2 Programmatic Submission (via FormBridge)

```
// Used by Members, Transfers, Children modules
$result = FormBridge::submit('NEW_MEMBERSHIP', $formData, $memberId, 'note');

Internally:
1. Find schema by code
2. Validate data (same as UI path)
3. Generate form_number
4. Create FormSubmission with member_id link
5. Dispatch form.submitted event
6. Return ['success' => true, 'submission_id' => X, 'form_number' => Y]
```

### 10.3 Schema Editing (Form Builder)

```
1. GET /forms/builder/{id} — Load schema for editing
2. POST /forms/builder/{id} — Update schema
   - Validate JSON is parseable
   - Increment version number
   - Update name_ar, form_fee, schema_json
```

### 10.4 Submission Status Lifecycle

```
draft → submitted → under_review → approved / rejected
                                  → expired (based on expires_at)
```

Valid statuses: `draft`, `submitted`, `under_review`, `approved`, `rejected`, `expired`

---

## 11. High-Risk Areas

### 11.1 Schema Version Drift
- Submissions store `schema_version` at submission time
- If a schema is edited after submissions exist, old submissions reference a stale version
- The show/print views use the CURRENT schema JSON (from form_schemas table), not the version at submission time
- This means: field labels, types, or sections may change after submission, causing display issues

### 11.2 Form Number Sequencing
- Uses MAX() on `form_number LIKE '{PREFIX}-{YEAR}-%'` to find next number
- Race condition: concurrent submissions could generate duplicate numbers
- If the pattern match fails to find the prefix, it starts from 1
- Different form_codes that share the same 3-char prefix would collide (e.g., two forms both starting with 'ADD')

### 11.3 JSON Schema Validation
- Schema JSON is stored as a JSON column but validated only by `json_decode()` on edit
- No structural validation (missing required keys, invalid field types, circular visible_when references)
- A malformed schema could crash the renderer or validator at runtime

### 11.4 Dynamic Permission Registration
- Bootstrap queries `form_schemas` table to register permissions
- If the DB connection fails or table doesn't exist, permissions silently aren't registered (wrapped in try/catch)
- This means per-form access control would be bypassed — users could access any form
- However, the route still requires `forms.view` base permission

### 11.5 No Schema Creation Route
- `forms.create_schema` permission is registered but no route exists to create new schemas
- New schemas must be inserted directly into the database
- The builder only edits existing schemas

### 11.6 Repeatable Sections
- Repeatable sections use field key suffixes (`_1`, `_2`, etc.) for additional rows
- Validation only applies to the base fields, not the indexed variants
- No server-side enforcement of `max_repeats` — only client-side

---

## 12. Configuration Dependencies

| Config Key | Purpose |
|-----------|---------|
| form_schemas.form_fee | Per-form fee (used by FormBridge::getFee) |
| form_schemas.validity_days | Auto-expiration period |

No system_config or Rule Engine dependencies — all configuration is in the form_schemas table itself.

---

## 13. Known Patterns & Gotchas

1. **FormBridge is the public API**: Other modules should ONLY interact through FormBridge, never directly with FormSchema/FormSubmission models
2. **No soft delete on submissions**: Submissions are never deleted — status changes track lifecycle
3. **Version-unaware display**: Show/print always uses current schema version, not the version at submission time
4. **CSRF token stripping**: Both FormController and FormBridge strip `_csrf_token` from submitted data before storage
5. **Dynamic options cache**: `loadDynamicOptions()` queries the DB on every render — no caching
6. **Bootstrap queries DB**: The bootstrap.php runs a SELECT on `form_schemas` during app boot — if this fails, the app still boots but per-form permissions are missing
7. **Prefix collision risk**: Form numbers use first 3 chars of form_code — `ADDITION_CHILD`, `ADDITION_SPOUSE`, `ADDITION_TEMPORARY` all produce prefix 'ADD'
8. **No form_code uniqueness enforcement in code**: Only the DB UNIQUE constraint prevents duplicate form_codes
9. **FormRenderer handles NID parsing**: Fields of type `national_id` get a `data-nid-parser="true"` attribute and the `auto_parse`/`populates` system for client-side auto-fill
10. **File upload fields**: The schema supports `file` type fields, but no server-side file handling exists in the Forms module — files would need custom handling in the consuming module
