# Adding Custom Fields

https://dev.wisecp.com/es/adding-custom-fields

Store extra information against a client or a ticket without adding a column.

## Overview

Two engines share the name. Client profile fields live per language in `users_custom_fields` with key/value values; ticket fields in `tickets_custom_fields` on two axes.

Neither needs a schema change: a definition row, and the value lands in a generic store.

## Prerequisites

- An operator account, or a token with the settings and tickets scopes.
- Familiarity with hooks: transformation is done with two filters.
- Installed language codes: **one row per language**, so two languages are two ids.

## Structure

Definition and value are separate; the value row is named `field_` plus the id.

| Table | Holds | Keyed by |
| --- | --- | --- |
| `users_custom_fields` | Language, type, label, options, flags, rank | `id`, one row per language |
| `users_informations` | The value, as text | `owner_id` plus `field_{id}` |
| `tickets_custom_fields` | The ticket field and its department | `id`; label and type in the `_lang` twin |
| `tickets_access_groups` | The reply-side grouping and its field ids | `id`; the relation lives on the group |

The store is untyped text: multi-choice persists comma joined, an empty answer is an empty string.

## Walkthrough

### Define the Field

1. Create the definition once per language, from Settings or the client-fields API.
2. Choose the type: `text`, `textarea`, `select`, `checkbox`, `radio`; choice types take an `options` list.
3. Set the visibility flags; they are independent booleans and they compose.
4. Note the id the row is given. Your code uses the id, not the label.

### Read and Write the Value

1. Write with the info helper, keyed by `field_{id}`.
2. Read with the info reader: a name with no row comes back as `null`, never as a missing key.
3. The reader memoises per request; pass the third argument to bypass it after a write.
4. Delete rather than blank when "not answered" is meaningful: only a removed row triggers a fallback.

### Transform the Value

1. Listen on the save filter: once per `field_*` key, before the write.
2. Listen on the load filter: on every read, after the rows are collected.
3. Keep the two symmetric, or ciphertext ends up on the profile screen.
4. Guard on the field id: both filters fire for **every** custom field.

## Reference

### Definition Flags

Five of six are integers holding 0 or 1; `status` is a string, and treating it alike reads every field as disabled.

- **status**: `active` or `inactive`; an inactive field appears only in the operator's list.
- **required**: An empty answer feeds the completeness gate.
- **uneditable**: Visible but not writable; the profile save loop skips these fields.
- **client_hidden**: Operator only, and it **overrides the others**: dropped from sign-up, invoice and required.
- **invoice**: Printed on the invoice view; empty values are dropped.
- **signForm**: Asked during registration; without it, only reachable from the profile screen.

### The Value Helper

```php
// Upsert. $values is a map of name => value; a custom field's name is "field_{id}".
// Returns true when at least one row was written.
public static function AddInfo($owner_id = 0, $values = []);
public static function setInfo($owner_id = 0, $values = []);   // alias of AddInfo

// Read. $names is an array (a comma separated string is also accepted and split).
// Returns a map with EVERY requested name present; a missing row reads as null.
// $noCache = true skips the per-request memo, which you need right after a write.
public static function getInfo($owner_id = 0, $names = [], $noCache = false): array;

// Remove rows entirely. $name is a single name or an array of names.
public static function deleteInfo($owner_id = 0, $name = ''): int|bool;

// Row id of an existing value, 0 when there is none.
public static function isInfo($owner_id = 0, $name = ''): int;

// Reverse lookup: which members hold this value. $first = true returns one row.
public static function findInfo($name = '', $value = '', $first = true): array;

// The completeness gate. Returns [field key => label] for everything still empty;
// an empty array means the account may use the client area.
public static function missing_required_fields($id = 0): array;
```

- **User::AddInfo()**: One row per entry. `phone` and `company_name` mirror onto the member record.
- **User::getInfo()**: Memoised per request, storing the **raw** value, so the load filter still runs.
- **User::missing_required_fields()**: Admin required fields plus the built-in ones, per language.
- **Order trap: findInfo()**: It takes the **name first, value second**: it searches across members. A member id there returns an empty array.
- **Value shape**: All text. A `checkbox` is comma joined on save; the other four are plain strings.

### The Two Value Filters

Both fire by reference and are scoped to `field_` keys, so `timezone` never reaches them.

```php
// Fired from User::AddInfo(), immediately before the insert or update.
// $value      mixed, by reference. What you leave here is what is stored.
// $field_id   int, the definition id parsed out of "field_5".
// $owner_id   int, the member the value belongs to.
Hook::add('filter:custom_field.save_value', 10, function (&$value, $field_id, $owner_id) {
    // ...
});

// Fired from User::getInfo(), after the rows are collected and before they are returned.
// Same three arguments, same order.
Hook::add('filter:custom_field.load_value', 10, function (&$value, $field_id, $owner_id) {
    // ...
});
```

- **filter:custom_field.save_value**: The persistence boundary. Change `$value` in place; the return is not used.
- **filter:custom_field.load_value**: The read boundary: decrypt, format, or inject. By reference; the return is not used.
- **action:client.profile_updated**: Fires after a profile save with the member id, the changed columns and the info map. A key means saved, not changed. The return is ignored.

### Where the Definitions Are Read

Every client surface applies its own filter, and they deliberately disagree.

| Surface | Reader | Filter it applies |
| --- | --- | --- |
| Account profile | `get_custom_fields()`, account model | active, not hidden, member's language, by rank |
| Registration form | `get_custom_fields()`, sign model | the same, plus `signForm = 1` |
| Invoice view | `invoice_custom_fields()` | `invoice = 1`; empty values dropped |
| Validation of one field | `get_custom_field()` | the list filter, on one id |
| Operator surfaces | `get_custom_fields()`, users model | language only; hidden included |

### Ticket Fields and Their Two Axes

One definition table, two composers; the two memberships live in different places.

```php
// Department axis, used by the ticket CREATE form. $did = 0 returns every field.
// Rows carry the localized name, description, type, properties and options, plus
// department_name. Ordered by did DESC, then rank ASC.
public static function custom_fields($lang = '', $did = 0, $status = '');

// Access-group axis, used by the REPLY composer's credential rows. Reads the group's
// own comma separated list of field ids and returns them IN THAT ORDER, which is the
// drag-and-drop order the operator set. An empty group returns [].
public static function custom_fields_by_group($lang = '', $groupId = 0, $status = 'active');

// The groups themselves, for building the selector.
public static function access_groups($lang = '', $status = 'active');
public static function get_access_group($id = 0, $lang = '', $select = '');
```

- **Tickets::custom_fields()**: Department axis. An empty `$status` returns inactive definitions too.
- **Tickets::custom_fields_by_group()**: Access-group axis; values are encrypted under their own key namespace and shown masked.
- **filter:ticket.custom_fields**: Filters the department-axis list (language, department, status). By reference, return not used.
- **filter:ticket.access_group_fields**: The same for the access-group list; the return is not used.
- **Ticket field types**: text, textarea, number, password, select, radio, checkbox. A `password` skips the sanitiser.

### Over the API

| Endpoint | What it does | Note |
| --- | --- | --- |
| `GET /settings/client-fields` | Lists definitions | Language is a parameter |
| `POST /settings/client-fields` | Creates one definition | Two languages, two calls |
| `PATCH /settings/client-fields/{id}` | Updates one definition | Not partial for flags: an omitted flag becomes 0 |
| `PUT /settings/client-fields/order` | Reorders the list | Rank drives the display order |
| `GET /tickets/custom-fields` | Lists ticket definitions | Separate engine and scope |

## Example

A field whose value must never sit in the database in the clear.

```php
// The definition ids of the same logical field in each installed language. A client
// field is one row per language, so a single label maps to several ids.
const ACME_TAX_FIELD_IDS = [11, 12];

Hook::add('filter:custom_field.save_value', 10, function (&$value, $field_id, $owner_id) {
    if (!in_array($field_id, ACME_TAX_FIELD_IDS, true)) return;

    $value = trim((string) $value);
    if ($value === '') return;                      // an empty answer stays empty

    $value = Crypt::encode($value, Config::get('crypt/system') . '_ACME_TAX');
});

Hook::add('filter:custom_field.load_value', 10, function (&$value, $field_id, $owner_id) {
    if (!in_array($field_id, ACME_TAX_FIELD_IDS, true)) return;
    if ((string) $value === '') return;

    // decode() returns false on a value that was written before this listener existed,
    // so the original text is kept rather than blanking the member's answer.
    $plain = Crypt::decode((string) $value, Config::get('crypt/system') . '_ACME_TAX');
    if ($plain !== false) $value = $plain;
});
```

The reading side of the same contract; nothing downstream knows the value was encrypted.

```php
$uid = 42;

// Ask for the names you want. Every name comes back, missing rows as null.
$info = User::getInfo($uid, ['field_11', 'gsm']);
$tax  = (string) ($info['field_11'] ?? '');

// A multi-choice field is one comma joined string; split it back yourself.
$picked = array_values(array_filter(explode(',', (string) ($info['field_12'] ?? ''))));

// Write. The same names, the same "field_{id}" convention.
User::setInfo($uid, ['field_11' => 'GB123456789']);

// The reader memoises per request, so re-read with the third argument after a write.
$fresh = User::getInfo($uid, ['field_11'], true);

// Removing a row is not the same as writing an empty string: only the removal makes
// the account look like one that never answered.
if ($tax === '') User::deleteInfo($uid, ['field_11']);
```

The operator side, when your module ships its own field: one definition per language at install, and keep the ids.

```php
$ids = [];

foreach (['en' => 'VAT Number', 'tr' => 'Vergi Numarası'] as $lang => $label) {
    $ids[$lang] = WDB::insert('users_custom_fields', [
        'lang'          => $lang,
        'type'          => 'text',       // text | textarea | select | checkbox | radio
        'name'          => $label,
        'status'        => 'active',     // STRING, unlike every flag below
        'required'      => 0,
        'uneditable'    => 0,
        'client_hidden' => 0,
        'invoice'       => 1,
        'signForm'      => 1,
        'options'       => '',           // comma separated, for select/checkbox/radio
        'rank'          => 90,
    ]) ? WDB::lastID() : 0;
}

// Keep the ids: they are the only stable handle to the field, and they differ per language.
Config::setd('acme_vat_field_ids', Utility::jencode($ids));
```

## Pitfalls

> **A field is one row per language, and the ids differ**
> 
> No shared parent id joins the language variants, so hiding one leaves the other visible. Collect the ids as a set.

> **Hidden plus required would lock the member out**
> 
> The gate redirects until every required field is answered, so required lookups exclude hidden fields.

> **The singular lookup is what refuses the write**
> 
> A field left out of the list is a UI decision a forged post ignores; the singular reader is what refuses the write.

> **Adding a flag touches eight places, not one**
> 
> A new flag needs the column, the write path, the form and its JavaScript branches, the list column, the locale keys, both API directions and every client surface.

> **The invoice reader returns no ids**
> 
> It returns name and value pairs and drops empty values, so a lookup by id reports missing.

## Related Articles

- [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener)
- [Changing the Database Schema](https://dev.wisecp.com/en/changing-the-database-schema)
- [Domain Helpers](https://dev.wisecp.com/en/domain-helpers)
- [Adding a Custom Operation](https://dev.wisecp.com/en/adding-a-custom-operation)
- [Working Without Touching the Core](https://dev.wisecp.com/en/working-without-touching-the-core)
