Custom Fields
Configurable field definitions for the forms a project fills: prestarts and diaries, activities, dockets (subcontractor dockets included), materials dockets, issues, worker sign-up, Drug & Alcohol tests and declarations, and induction roles (managed in the induction role editors, below). Org admins define templates at the org level; when a project is created, org fields are auto-copied to the project. Project admins can then freely add, edit, delete, and reorder their project-level fields independently.
Eight authorable entity types — prestart_diary, activity, docket, materials_docket, worker_signup, issue, drug_alcohol_entry (Drug & Alcohol) and drug_alcohol_declaration (D&A Declaration). Prestarts and diaries share the same field definitions: when a prestart is published it creates a diary, and the prestart’s fields link over to that diary. When a diary is created fresh (not from a prestart), it pulls its custom fields from the project.
Architecture
Copy-on-inherit model:
- Org admins create field definitions (org-level templates)
- Templates auto-copied to all active projects on creation
- New projects receive all current org templates during project creation
- Project copies are fully independent after copying — edits do not propagate back
Single table with scope: custom_field_definitions uses a nullable project_id column to distinguish org templates (NULL) from project fields (set).
Data Model
Custom Field Definitions
| Column | Type | Description |
|---|---|---|
id | UUID | Primary key |
org_id | UUID | Foreign key to organizations |
project_id | UUID (nullable) | NULL = org template, set = project-level field |
entity_type | custom_field_entity | The form the field belongs to (any value under Enums) |
field_type | custom_field_type | text, textarea, number, checkbox, dropdown, radio, multiselect, date, time, file, user, year, day_of_week, address, csv_lookup, table |
label | TEXT | Display name (1-255 chars) |
description | TEXT (nullable) | Help text shown to users |
required | BOOLEAN | Whether the field is mandatory (default: false) |
options | JSONB (nullable) | ["Option A", "Option B"] for dropdown/radio/multiselect |
sort_order | INTEGER | Display ordering (default: 0) |
metadata | JSONB | Extensible unstructured data (default: {}) |
reporting_key | TEXT (nullable) | The standard report column a worker sign-up field fills (see Reporting column) |
deleted_at | TIMESTAMPTZ | Soft-delete timestamp (NULL = active) |
created_at | TIMESTAMPTZ | Auto-set on insert |
updated_at | TIMESTAMPTZ | Auto-updated via trigger |
Enums
custom_field_entity:prestart_diary,activity,docket,subcontractor_docket,materials_docket,issue,worker_signup,induction_role,org_default_induction_role,drug_alcohol_entry(Drug & Alcohol),drug_alcohol_declaration(D&A Declaration)custom_field_type:text,textarea,number,checkbox,dropdown,radio,multiselect,date,time,file,user,year,day_of_week,address,csv_lookup,table
Indexes
| Index | Columns | Condition | Purpose |
|---|---|---|---|
idx_cfd_org_templates | (org_id, entity_type, sort_order) | project_id IS NULL AND deleted_at IS NULL | Fast org template listing |
idx_cfd_project_fields | (org_id, project_id, entity_type, sort_order) | project_id IS NOT NULL AND deleted_at IS NULL | Fast project field listing |
idx_cfd_id_org | (id, org_id) | None | Tenant-isolated single lookup |
Copy-on-Inherit Flow
Creating an org template
- Admin creates a field via
POST /v1/orgs/{orgId}/custom-fields - Go service inserts the org template (
project_id = NULL) - Service queries
ListOrgProjectIDsto get all active projects - For each project, calls
CopyOrgFieldToProject(INSERT…SELECT) - Errors on individual project copies are logged but do not fail the request
Creating a new project
After project creation and admin auto-add (in project/service.go), CopyAllOrgFieldsToProject bulk-copies all current org templates to the new project in a single INSERT…SELECT query.
Independence after copy
Project-level fields are fully independent. Editing or deleting an org template does not affect existing project copies. Editing a project field does not affect the org template.
One exception: a template’s reporting column is copied to its project copies, and changing it on the template changes it on the copies, except in a project where another field already uses that column. There the copy has none. When that other field is deleted or gives up the column, the copy takes it.
Reporting column
A worker sign-up field can fill one standard report column: Apprentice, Trainee, Gender, Date of birth, Postcode, Year of commencement, Aboriginal or Torres Strait Islander, Language group or Beakon ID. Pick it in Reporting column when you add or edit the field.
- Each column can be used by one field per project, and by one organisation template.
- A column only appears for field types that suit it. Date of birth, for example, needs a Date or Text field.
- Fields that already had one of these labels were matched automatically. Where a project had two fields with the same label, the one with more answers was matched.
- Fields from an organisation template show their column in the project’s list; change it on the template.
API Endpoints
Org-Level Templates
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /v1/orgs/{orgId}/custom-fields | BearerAuth | List templates (?entity_type filter) |
POST | /v1/orgs/{orgId}/custom-fields | BearerAuth | Create template + auto-copy (customfields.manage) |
PUT | /v1/orgs/{orgId}/custom-fields/{fieldId} | BearerAuth | Update template (customfields.manage) |
DELETE | /v1/orgs/{orgId}/custom-fields/{fieldId} | BearerAuth | Soft-delete template (customfields.manage) |
Project-Level Fields
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /v1/orgs/{orgId}/projects/{projectId}/custom-fields | BearerAuth | List fields (?entity_type filter) |
POST | /v1/orgs/{orgId}/projects/{projectId}/custom-fields | BearerAuth | Create field (customfields.manage) |
PUT | /v1/orgs/{orgId}/projects/{projectId}/custom-fields/{fieldId} | BearerAuth | Update field (customfields.manage) |
DELETE | /v1/orgs/{orgId}/projects/{projectId}/custom-fields/{fieldId} | BearerAuth | Soft-delete field (customfields.manage) |
CSV Lookup Field Type (csv_lookup)
A csv_lookup field lets an admin upload a multi-column CSV/XLSX dataset and nominate N ordered “filter” columns (cascading) plus exactly 1 “target” column. At response time the user is shown a sequence of cascading dropdowns — picking Region=NSW filters the Site options, picking Site filters Trade, and once all filters are chosen the matching target value (e.g. a Cost Code) is presented and stored as a plain string in the entity’s custom_field_values. Other columns of the matched row can fill fields on the same form (see Filling the form’s fields).
- Dataset storage: the CSV is stored as rows in a dedicated
custom_field_lookup_rowstable (one row per line,cells JSONB), not in the definition. The column config (which columns are filters/target, their order, source filename, row count, the field map and the warnings) lives incustom_field_definitions.metadata.lookup. - Scope (v1): datasets are project-scoped — each project uploads its own CSV via the Dataset action on the field row. A new project inherits the field shape + column config (the copy queries carry
metadata) but starts with no dataset until uploaded. - Organisation lookups: a lookup copied from an organisation template takes a dataset upload in each project, but its field map is read-only there (like every other setting of an organisation field).
- Cascade endpoint: a server-side distinct-values endpoint drives the dropdowns; the client never downloads the whole table. Input is validated so
columnand everyfilterskey must be a configured column and a prefix-subset of the cascade (IDOR/probe defence). - Entity types: allowed on
prestart_diary,activity,docket,materials_docket,issue. Not onworker_signup/induction_role(the cascade endpoint is member-auth’d; public sign-on cascade is a later slice). - Stored value: the chosen target string.
ValidateValuesenforces a non-empty string shape; the cascade is the integrity boundary. A stored answer shows asCurrent: {value}with Change and Clear; filter picks never clear it, and no cascade read is sent until Change is pressed.
Filling the form’s fields
Each dataset column can fill one field on the form the lookup is on. The admin chooses it per column in the Fills select of the Dataset dialog (“Form fields” first, then “Custom fields”); it is saved as metadata.lookup.field_map:
"field_map": [
{ "column": "description", "target": { "kind": "builtin", "key": "description" } },
{ "column": "unit", "target": { "kind": "builtin", "key": "target_qty_unit" } },
{ "column": "crew_notes", "target": { "kind": "custom_field", "field_id": "<uuid>" } }
]columnis the normalised column key (Cost Code→cost_code).- A
builtintarget is one of the form’s own header fields (below). Acustom_fieldtarget is a live custom field of the same entity type, other than files, tables and lookups. - One lookup per field per form. A second mapping of the same field is refused with
{field} is already filled by {lookup}.docketandmaterials_docketlookups count together at setup, although a materials docket shows only itsmaterials_docketfields. A field no form can edit is refused with{field} can't be filled by a lookup. - The mapping is checked and saved in one transaction under the lookup’s lock.
Form fields a lookup can fill (the Fills select offers exactly these):
| Entity type | Form fields |
|---|---|
activity | Stage, Cost code, Cost code description, Description, Target qty, Target qty unit, Rate, Rate unit, Logged qty, Logged qty unit |
prestart_diary | Supervisor (prestart), Date and time (prestart), Prestart duration (prestart), Shift (prestart), Site location, Location, Activity description (diary), Handover notes (diary) |
docket | Date, Start time, End time, Floor level, Crew size, Leading hand, Notes |
materials_docket | Delivery docket no., Source location, Delivery location, Notes, Date, Shift |
issue | Heading, Cause, Impact, Location, Reference document, Cost code, Cost code description, Identified date, Notified date, Notification deadline, Submitted date, Assessed date, Agreed date, Cost impact, Identified value, Notified value, Submitted value, Assessed value, Agreed value |
Never targets: lines (workers, plant, materials, subcontractors), the pricing grid, docket money, docket and materials contract, plant attribution, statuses, approvals and assignees. The key vocabulary is kept identical in three places, each guarded against the others: openapi CustomFieldLookupBuiltinKey, packages/lookup-fill/src/lookup-targets.ts and Go backend/api/internal/customfield/lookup_targets.go.
What a pick fills
Once a person picks the final answer (or a filter pick leaves one answer, which is then chosen for them), the form reads the matched rows once — only when the lookup has a field map, never for a filter pick. Then:
- Empty fields fill straight away. Empty means no value, an empty text or list, or an unticked checkbox. A value the form put there itself, that the person hasn’t touched, also counts as empty.
0is a value. - Fields that already hold a different value are listed in Replace existing values? — Replace fills them, Keep mine keeps them.
- If several rows match the answer and differ on a field this form shows, Pick a row asks which one to use. Rows that give the same values are one row.
- Values that don’t match (an unknown unit, stage, location or person) are left alone and named under Couldn’t fill: … — inline under the lookup on the web, a toast on the phone (an inline note on the phone’s activity screen).
- Start and end times that would end up equal are both left as they were and named under Couldn’t fill.
- Stage before cost code. When a fill changes the stage, the stage, cost code and cost code description move together; a new stage without a mapped cost code clears the cost code, as changing the stage by hand does.
- A form fills only the fields it shows; the rest are skipped. An empty cell fills nothing.
How cells are read
Matching ignores capitals and outer spaces.
| Field kind | Accepted |
|---|---|
| Date | Day first 07/10/2026 (/, - or .), 7/10/26, or 2026-10-07 |
| Time | 06:30, 16:30, 4:30 pm, 4pm — the wall clock, never shifted through a zone |
| Date and time | A date and a time separated by a space (07/10/2026 6:30 am), 2026-10-07T06:30, or RFC 3339 (read in the project’s time zone) |
| Person (supervisor, user field) | Email, or a full name no one else on the project has |
| Checkbox | yes/no, true/false, 1/0, y/n |
| Number, money | 120, 1,200.5, $85.00, -3 (thousands commas in threes) |
| Crew size | A whole number ≥ 0 |
| Stage | The stage’s number, its name, or number — name (also with -); a cell naming two stages matches none |
| Location | The project location’s name |
| Shift | day, afternoon, night |
| Prestart duration | 0, 15, 30, 45, 60 (optionally followed by min, mins, minutes) |
| Dropdown, radio | One of the field’s options |
| Multi-select, days | Options separated by commas |
| Address | State-only fields take an AU state; street-only fields take the text; full and suburb + postcode addresses don’t fill |
| Unit | m, mm, km, m² (m2), m³ (m3), ha, L (litre, litres), kg, t (tonne, tonnes), ea (each), lot, load (loads), trip (trips), hr (hrs, hour, hours), shift (shifts), day (days), week (weeks), month (months) |
Setup warnings
The server works the warnings out over the rows on every upload and every mapping save, and stores them as metadata.lookup.warnings (read-only; a save never sends them). They show under the columns in the Dataset dialog:
| Stored | Shown as | What to do |
|---|---|---|
unmatched_unit_rows | {n} rows have a unit we don't recognise | Fix the spelling (see Unit above) and upload again |
clashing_answers | {n} answers match more than one row, then the answers in a scrolling list | Those answers ask the person to pick a row; keep one row per answer to avoid it |
dropped_columns | No longer mapped: {column} | A new upload lost a mapped column; add it back, or save the mapping to clear it |
CSV-Lookup endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /v1/orgs/{orgId}/projects/{projectId}/custom-fields/{fieldId}/lookup-dataset | BearerAuth | Upload/replace the CSV/XLSX dataset (customfields.manage) |
GET | /v1/orgs/{orgId}/projects/{projectId}/custom-fields/{fieldId}/lookup-values | BearerAuth | Distinct values for a column given upstream filters |
GET | /v1/orgs/{orgId}/projects/{projectId}/custom-fields/{fieldId}/lookup-match | BearerAuth | The rows a completed pick matches (?filters=<json>&target=<answer>): each row’s mapped cells, the value each gives its field, and the fields it couldn’t match; at most 50 |
lookup-match refuses a partial pick (a missing or extra filter column, or an empty target) with 400, and answers 404 for an unknown, other-organisation or non-lookup field. It is not offered to the Copilot.
Table Field Type (table)
A table field lets an admin define a grid of typed columns; users then add free-form rows when filling in a record. It behaves like a lightweight embedded spreadsheet attached to a prestart, diary, docket, or other entity.
Column configuration (admin)
Each column has:
| Property | Values | Notes |
|---|---|---|
| Name | Free text | Shown as the column header |
| Type | text · number · date · checkbox · dropdown | Determines input widget and cell rendering |
| Required | Toggle | Marks the column with a required indicator; validated on submit |
| Sum | Toggle (number columns only) | When enabled, a totals row is appended below the last data row |
The column config is stored in custom_field_definitions.metadata.columns as a JSON array.
Data entry (user)
- Users add, edit, and remove rows freely within the field.
- Each row is a map of
{ columnId → cellValue }stored in the entity’scustom_field_values. - Columns marked Sum display an auto-calculated totals row at the bottom (read-only).
v1 limits
- Maximum 12 columns per table field.
- Maximum 100 rows per instance.
Rendering
The table renders in all three surfaces:
| Surface | Behaviour |
|---|---|
| Web | Editable inline grid with per-row add/delete; summed totals row shown |
| PDF export | Full table printed with column headers and a totals row where applicable |
| Mobile | Scrollable read/write table with per-row actions |
Entity availability
Available on: prestart_diary, activity, docket, materials_docket, issue.
Not available on worker_signup or induction_role — the row-entry UI requires an authenticated member session.
UI
Org Configuration Page
Located at /org/configuration. Accessible via the “Configuration” sidebar nav item (between Users and Settings).
- Entity type filter: Nine chips (All / Prestart & Diary / Activity / Docket / Materials Dockets / Worker Sign-Up / Issues / Drug & Alcohol / D&A Declaration) toggle which fields are shown
- Table columns: Label, Entity, Type, Required, Options, Sort Order, Actions (Edit / Archive)
- Create dialog: Entity type selector, field type selector, label, description, required toggle, options editor (shown for dropdown/radio/multiselect), sort order
- Edit dialog: Same as create but entity type and field type are read-only (shown as badges)
- Query key:
["org-custom-fields", orgId, entityType]
Project Custom Fields Tab
Located in the project detail view (/admin/config) as the fourth tab alongside Overview, Stages, and Members.
- Same structure as the org tab but uses project-level server actions
- Query key:
["project-custom-fields", orgId, projectId, entityType] - Sections: Fields, Sign-on acknowledgements and Guide, switched in
place from the header (the URL’s
?section=is rewritten without a route change). - Sign-on acknowledgements: the project’s sign-on acknowledgements — the items a
worker answers before signing themselves on (see
Sign-Ons). It is a panel on this tab, not a
custom-field entity type, and it is per-project: the org tab has no equivalent.
Deep-linkable as
/admin/config?tab=custom-fields§ion=sign-on-acknowledgements. The panel also holds the project setting Allow workers to sign on prior to the prestart (Yes or No, No by default); it saves on its own, with the last saved questions. - Guide: how CSV lookups work, deep-linkable as
/admin/config?tab=custom-fields§ion=guide(each part also has an anchor, e.g.#guide-warnings). It covers what a lookup does, what each column fills, what happens on a form, how to write values, the warnings, “Couldn’t fill”, and a made-up sample CSV (built in the browser). For a manager, the Dataset dialog’s warnings link to its Warnings part. Its Start here has two buttons, shown only withcustomfields.manage:- Add a CSV lookup opens the new-field dialog with CSV Lookup chosen;
- Set up a dataset opens the new-field dialog when the project has no lookup, that lookup’s Dataset dialog when it has one, and a short list to choose from when it has several.
Real-Time Updates
The custom_field_definitions table has a LISTEN/NOTIFY trigger that fires on org_changes. The WebSocket provider maps this to invalidate both org-custom-fields and project-custom-fields query keys.
Security
customfields.managefor every change — create, update, archive, reorder, dataset upload and mapping saves all need thecustomfields.managepermission (checked by the Go API). The web hides the setup controls and the Guide’s Start here buttons without it.- Tenant isolation — Every query includes
AND org_id = $N. Handlers verify JWTorg_idmatches the URL{orgId}parameter. - Soft deletes — No permanent data removal. Archived fields can be restored in the future.
- Double input validation — Zod schemas in the BFF layer + Go struct validation with enum checks and length limits.
- Error-tolerant fan-out — If copying a template to one project fails, other projects still receive the copy.
Key Files
| File | Purpose |
|---|---|
backend/db/migrations/006_custom_fields.sql | Enums, table, indexes, triggers |
backend/db/queries/custom_fields.sql | sqlc queries: CRUD + copy-on-inherit |
backend/api/internal/customfield/service.go | Business logic + fan-out copy |
backend/api/internal/customfield/handler.go | HTTP handlers + routes (org + project) |
apps/web/src/actions/custom-fields.ts | BFF server actions (8 functions) |
apps/web/src/app/org/configuration/page.tsx | Org configuration page (RSC) |
apps/web/src/app/org/configuration/_components/custom-fields-tab.tsx | Org custom fields CRUD UI |
apps/web/src/app/admin/_components/project-custom-fields-tab.tsx | Project custom fields CRUD UI |
apps/web/src/app/admin/_components/custom-fields-guide/ | The tab’s Guide section |
packages/lookup-fill/src/lookup-targets.ts | Fields a lookup can fill (catalogue) |
packages/lookup-fill/src/fill.ts | What a pick fills (web and phone) |
backend/api/internal/customfield/lookup_match.go | Matched rows for a completed pick |
backend/api/internal/customfield/lookup_warnings.go | Setup warnings |
Short Term Induction Role fields are not managed here
Custom fields on a Short Term Induction Role do not appear in the Custom Fields tabs (org or project), and cannot be created from them. They are managed in the induction role editors:
| Scope | Where | Stored entity_type |
|---|---|---|
| Org defaults | Org → Inductions → <role> | org_default_induction_role |
| Per project | Admin → Inductions → <role> | induction_role |
Both editors support create, edit, reorder and delete.
The reason is not cosmetic. A row created from a Custom Fields tab carries entity_id = NULL, but the induction sign-on renderer only reads fields whose entity_id matches the role — so a field authored from the tab was a dead row that never appeared on any induction form. The tabs now filter these rows out even under the All filter, because the entity filter is applied server-side and All sends no entity_type at all.
induction_role remains a valid entity type in the Go API and in the database; nothing changed server-side.