CRM Actions — Custom Fields (BE) — Task Breakdown
RFC:
crm-actions-custom-fields.mdMode: Vertical (one task per RFC chunk) · Scope: BE only —chatbotRails monolith All tasks actionable — A-1 resolved 2026-07-15; no blocked tasks.
Effort Summary
| Task | BE days | QA days | Total |
|---|---|---|---|
| Task 1 — Feature flag rollout class (Chunk 1) | 0.5 | 0 | 0.5 |
Task 2 — AdditionalFieldNormalizer (Chunk 2) | 1 | 0 | 1 |
Task 3 — LookupResources extension (Chunk 3) | 1 | 0.5 | 1.5 |
Task 4 — Execute — merge_additional_field_arguments (Chunk 4) | 1.5 | 0.5 | 2 |
Task 5 — TrainAiAgent — build_non_api_args fix (Chunk 4B) | 0.5 | 0 | 0.5 |
| Task 6 — Node registry seed (Chunk 5) | 0.5 | 0 | 0.5 |
| Grand total | 5 | 1 | 6 |
Confidence: high. All chunks have verified file paths and complete code skeletons; A-1 resolved; existing rollout/normalizer/lookup patterns read and matched. No schema migrations required. Open item A-3 (nil-value handling) is a PM call — doesn't block implementation.
Task 1: [BE] Feature flag rollout class (Chunk 1)
A bot account can be enrolled in the additional-fields beta by adding its
company_idto a system preference; non-enrolled orgs are unaffected.
Status: ✅ Actionable
Stories: QACF-S03 (feature flag gates the BE merge)
What to build
New AiAgentActionCustomFields rollout class following the exact shape of rollout/crm_contact_lookup_by_ext_user_id.rb. Class method enabled_for?(organization) returns true if organization.company_id is in the JSON array stored in system_preferences.value for group_code: 'rollout', code: 'ai_agent_action_custom_fields'.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| read | app/core/repositories/system_preferences/rollout/crm_contact_lookup_by_ext_user_id.rb | Copy class/module shape; confirm FindBy, JSON.parse, rescue [], company_id look-up |
| create | app/core/repositories/system_preferences/rollout/ai_agent_action_custom_fields.rb | New rollout class (code: ai_agent_action_custom_fields) |
| create | spec/core/repositories/system_preferences/rollout/ai_agent_action_custom_fields_spec.rb | 3 contexts: id in list, id not in list, nil org |
| run (console) | db seed | SystemPreference.find_or_create_by! with value: '[]' |
Implementation steps
- Read the reference rollout class — open
app/core/repositories/system_preferences/rollout/crm_contact_lookup_by_ext_user_id.rb. Note exact module nesting (Repositories::SystemPreferences::Rollout),FindBycall signature, andrescue []pattern. Match exactly. - Write failing spec (red) — create
spec/core/repositories/system_preferences/rollout/ai_agent_action_custom_fields_spec.rbmirroringcrm_contact_lookup_by_ext_user_id_spec.rb. Threedescribe '.enabled_for?'contexts: company_id in list →true; not in list →false; nil org →false. Runbundle exec rspec spec/core/repositories/system_preferences/rollout/ai_agent_action_custom_fields_spec.rb— expect 3 failures. - Create the class —
app/core/repositories/system_preferences/rollout/ai_agent_action_custom_fields.rb, frozen_string_literal, RFC Chunk 1 body.group_code: 'rollout',code: 'ai_agent_action_custom_fields',cids.include?(organization.company_id). - Go green — run spec until all 3 pass.
- Seed — run in rails console:
SystemPreference.find_or_create_by!(group_code: 'rollout', code: 'ai_agent_action_custom_fields') { |sp| sp.name = 'AI Agent Action Custom Fields'; sp.group_name = 'Rollout'; sp.value = '[]'; sp.enabled = true; sp.description = 'Per-org rollout for custom fields on create deal/ticket actions. Value: JSON array of company_ids.' }. Verify value defaults to'[]'. - Quality gate —
bundle exec rubocop app/core/repositories/system_preferences/rollout/ai_agent_action_custom_fields.rb
Acceptance criteria
-
AiAgentActionCustomFields.enabled_for?(org_with_matching_company_id)returnstrue -
AiAgentActionCustomFields.enabled_for?(org_with_non_matching_company_id)returnsfalse -
AiAgentActionCustomFields.enabled_for?(nil)returnsfalse -
SystemPreferencerow created withvalue: '[]'(flag off by default) - All 3 spec cases pass
Test strategy
Stub FindBy call with factory-built system_preference whose value is a JSON array. Three cases: array includes org's company_id, array doesn't, org is nil.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| QA | 0 |
| Total | 0.5 |
Assumptions: direct copy of existing rollout pattern; no schema change; seed is a console one-liner.
Run to verify
bundle exec rspec spec/core/repositories/system_preferences/rollout/ai_agent_action_custom_fields_spec.rb
Depends on
Nothing — start immediately, parallel with Tasks 2 and 6.
Task 2: [BE] AdditionalFieldNormalizer (Chunk 2)
When the Add-field picker calls the lookup endpoint, CRM raw field definitions are converted into
PropertiesItem-shaped descriptors the FE can render without type-checking logic.
Status: ✅ Actionable
Stories: QACF-S01 (normalization contract for the picker)
What to build
New module AdditionalFieldNormalizer with one public class method normalize(raw_field). Maps CRM field_type_id integers (1/2/3/7/8/9) to { name:, value:, property: { is_additional_field:, html:, is_rl:, type:, display_name:, resource_types: } }. Returns nil for unknown type IDs.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| read | app/core/repositories/node_resources/mekari_qontak_crm/lookup_resources.rb:61-201 | Learn extract_data_from_response + filter_map pattern; confirm with_indifferent_access usage |
| create | app/core/repositories/node_resources/mekari_qontak_crm/additional_field_normalizer.rb | Full normalizer module with FIELD_TYPE_MAP + normalize + build_dropdown_items |
| create | spec/core/repositories/node_resources/mekari_qontak_crm/additional_field_normalizer_spec.rb | Unit tests: one context per supported field_type_id + nil for unknown |
Implementation steps
- Read lookup_resources.rb lines 61–201 — open file. Note module nesting,
with_indifferent_accessusage, how dropdown items handled in similar methods. Confirm no existingAdditionalFieldNormalizerorCustomFieldNormalizerin same directory. - Write failing spec (red) — create
spec/core/repositories/node_resources/mekari_qontak_crm/additional_field_normalizer_spec.rb. Onedescribe '.normalize'with contexts for:field_type_id: 1(text),2(dropdown, 3 items),3(number),7(percentage → number),8(textarea),9(url), unknown id (→ nil). Run — expect all failures. - Create normalizer —
app/core/repositories/node_resources/mekari_qontak_crm/additional_field_normalizer.rbwith RFC Chunk 2 body. Notehtml[:type] = config[:html_type] if config[:html_type]—textareaandselectmust NOT havehtml.typekey. - Build dropdown items —
build_dropdown_itemsisprivate_class_method. Forfield_type_id: 2,resource_typesarray contains one element{ type: 'fixed', items: [...] }fromfield[:dropdown].filter_map. - Go green — run spec until all 7 contexts pass.
- Quality gate —
bundle exec rubocop app/core/repositories/node_resources/mekari_qontak_crm/additional_field_normalizer.rb
Acceptance criteria
-
field_type_id: 1→html.element: 'input',html.type: 'text',is_rl: false,resource_types: nil -
field_type_id: 2with 3 dropdown items →html.element: 'select', nohtml.type,is_rl: true,resource_types: [{ type: 'fixed', items: [{name:, value:} x3] }] -
field_type_id: 3→html.type: 'number',prop_type: 'number' -
field_type_id: 7(percentage) → same as number -
field_type_id: 8→html.element: 'textarea', nohtml.type,prop_type: 'string' -
field_type_id: 9→html.type: 'url' - Unknown
field_type_id→nil -
display_nameprefersname_aliasovername_localeover rawname
Test strategy
Pure unit test — no HTTP stubs. Pass raw hashes matching confirmed CRM response shape. Assert full returned hash shape including nested property keys. Test nil return for unknown type.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1 |
| QA | 0 |
| Total | 1 |
Assumptions: no DB or HTTP; pure data transformation; 7 spec cases straightforward.
Run to verify
bundle exec rspec spec/core/repositories/node_resources/mekari_qontak_crm/additional_field_normalizer_spec.rb
Depends on
Nothing — start parallel with Tasks 1 and 6.
Task 3: [BE] LookupResources — additional_field_deal + additional_field_ticket (Chunk 3)
When the FE Add-field picker calls
POST /v1/node-resources/lookupwithresource_key: additional_field_dealoradditional_field_ticket, it gets a list of org-level CRM additional fields ready to render.
Status: ✅ Actionable
Stories: QACF-S01 (load custom fields for picker)
What to build
Extend LookupResources: add two RESOURCE_PATHS entries, two when branches in call, and one shared private method fetch_additional_fields(resource_key) that calls crm_http_client.request_with_auth, runs through extract_data_from_response, calls AdditionalFieldNormalizer.normalize, Rollbar-warns on nil, rescues with [] + Rollbar error.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| read | app/core/repositories/node_resources/mekari_qontak_crm/lookup_resources.rb:1-57 | Confirm RESOURCE_PATHS constant location, call dispatch structure, when indent style |
| read | app/core/repositories/node_resources/mekari_qontak_crm/lookup_resources.rb:61-201 | Confirm fetch_* private method pattern, auth_error?, extract_data_from_response, filter_map, rescue block shape |
| extend | app/core/repositories/node_resources/mekari_qontak_crm/lookup_resources.rb | Add 2 RESOURCE_PATHS, 2 when branches, 1 private fetch_additional_fields(resource_key) |
| extend | spec/core/repositories/node_resources/mekari_qontak_crm/lookup_resources_spec.rb | Add contexts for resource_key: 'additional_field_deal' and 'additional_field_ticket' |
Implementation steps
- Read lines 1–57 — open file. Note exact
RESOURCE_PATHShash position, howcalluseswhen, whether it'scase resource_keyorcase @resource_key. Note whether private methods are in aprivateblock or useprivate def. - Read lines 61–201 — pick one existing fetch method (e.g.
fetch_crm_deal_pipelines). Note: doesauth_error?check before or afterfilter_map? Doesextract_data_from_responsereturnresponse[]array? DoesrescuecatchStandardErroror bare=> e? - Write failing specs — extend
spec/core/repositories/node_resources/mekari_qontak_crm/lookup_resources_spec.rbwith two newdescribeblocks. For each: stubcrm_http_client.request_with_authwith canned CRM response (2 fields: one dropdown, one text, one field_type_id: 99), assert result has 2 items withproperty.is_additional_field: true. Sub-contexts: auth error, network error (→[]+ Rollbar error), unsupported type (excluded + Rollbar warning). - Extend
RESOURCE_PATHS— add two entries at end of hash. - Extend
call— add twowhenbranches per RFC Chunk 3. - Add
fetch_additional_fields— new private method per RFC Chunk 3 body. - Go green —
bundle exec rspec spec/core/repositories/node_resources/mekari_qontak_crm/lookup_resources_spec.rb— run full file, not just new examples. - Quality gate —
bundle exec rubocop app/core/repositories/node_resources/mekari_qontak_crm/lookup_resources.rb
Acceptance criteria
-
resource_key: 'additional_field_deal'issuesGET /api/mobile/v2.8/crm/additional_fields?object=deal&created_by=user -
resource_key: 'additional_field_ticket'issuesGET /api/mobile/v2.8/crm/additional_fields?object=ticket&created_by=user - Both return items with
property.is_additional_field: true - Dropdown field:
property.is_rl: true,resource_typespopulated - Non-dropdown:
property.is_rl: false,resource_types: nil - Unsupported
field_type_id→ excluded +Rollbar.warningwithresource_key,field_type_id,field_name,organization_id - Auth error →
auth_error?path - Network error →
[]+Rollbar.errorwithresource_key,organization_id - All existing
lookup_resources_spec.rbcases still pass
Test strategy
Stub crm_http_client.request_with_auth at instance level matching existing spec's stub pattern. Canned CRM response JSON with field_type_id 2, 1, 99. Assert returned array length = 2 and shape. Use allow(Rollbar).to receive(:warning) + have_received assertion.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1 |
| QA | 0.5 |
| Total | 1.5 |
Assumptions:
fetch_*pattern is straight copy with normalizer call substituted; 0.5 QA for config-time field rendering.
Run to verify
bundle exec rspec spec/core/repositories/node_resources/mekari_qontak_crm/lookup_resources_spec.rb
Depends on
- Task 2 (
AdditionalFieldNormalizermust exist)
Task 4: [BE] Execute — merge_additional_field_arguments (Chunk 4)
When creating a deal or ticket, additional fields configured by the bot builder (static or AI-filled) are merged into the CRM request body as
additional_fields[]— gated by the per-org feature flag.
Status: ✅ Actionable
Stories: QACF-S03 (merge mapped custom fields into the CRM create)
What to build
Extend execute.rb: add additional_fields_enabled? (memoized flag check) and merge_additional_field_arguments(body, param_config) (iterates parameters['arguments'], filters is_additional_field: true, resolves static vs AI-filled values, builds additional_fields[] array). Insert conditional call in perform_execution after enrich_body_for_deal_create.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| read | app/core/repositories/node_executions/nodes/mekari_qontak_crm/execute.rb:56-105 | Confirm perform_execution body — find insert point after enrich_body_for_deal_create |
| read | app/core/repositories/node_executions/nodes/mekari_qontak_crm/execute.rb:186-208 | Confirm extract_arguments_by_destination skips unregistered keys |
| extend | app/core/repositories/node_executions/nodes/mekari_qontak_crm/execute.rb | Add 3-line flag-gated call in perform_execution; add 2 private methods |
| extend | spec/core/repositories/node_executions/nodes/mekari_qontak_crm/execute_spec.rb | New contexts: flag true/false, value resolution (static/AI/nil), value_name fallback, multi-field array, no-regression |
Implementation steps
- Read
execute.rb:56-105— locateperform_execution. Find line afterenrich_body_for_deal_createcalled and before HTTP call. That is the insert point forbody = merge_additional_field_arguments(body, param_config) if additional_fields_enabled?. Confirm@organization_idand@argumentsare instance vars. - Read
execute.rb:186-208— confirmis_additional_field: truekeys getnilfromnode_registry_destination_forand are skipped at line 193–194. Proves separate merge method is necessary. - Write failing specs — extend
spec/core/repositories/node_executions/nodes/mekari_qontak_crm/execute_spec.rb. Adddescribe '#perform_execution' context 'with additional fields enabled'. Alsodescribe '#merge_additional_field_arguments'unit tests for edge cases (nil value → omitted, use_ai + resolved value, nocached_result_name→ value_name = value). Run full spec file — expect new examples to fail. - Add
additional_fields_enabled?— new private method per RFC Chunk 4. Memoized withdefined?(@additional_fields_enabled). CallsOrganization.find_by(id: @organization_id)thenAiAgentActionCustomFields.enabled_for?. - Add
merge_additional_field_arguments— new private method per RFC Chunk 4. Iterateparam_config,filter_map, build{ 'id', 'name', 'value', 'value_name' }. Setbody['additional_fields'] = additional_items if additional_items.any?. - Wire into
perform_execution— insertbody = merge_additional_field_arguments(body, param_config) if additional_fields_enabled?afterenrich_body_for_deal_createcall. - Go green —
bundle exec rspec spec/core/repositories/node_executions/nodes/mekari_qontak_crm/execute_spec.rb— run full file. - Quality gate —
bundle exec rubocop app/core/repositories/node_executions/nodes/mekari_qontak_crm/execute.rb
Acceptance criteria
- Flag
true+is_additional_field: true, staticvalue: "12",cached_result_name: "admin"→body['additional_fields'] = [{ 'id' => 1, 'name' => 'role_additional_field', 'value' => '12', 'value_name' => 'admin' }] - Flag
true+ nocached_result_name→value_namefalls back to value itself - Flag
true+use_ai: true+@arguments = { 'role_additional_field' => '99' }→value: '99',value_name: '99' - Flag
true+value: nil(AI field, no runtime value) → field omitted from array - Flag
true+ multiple additional fields → one item per field inbody['additional_fields'] - Flag
false→merge_additional_field_argumentsnever called; body unchanged - No
is_additional_field: trueentries →body['additional_fields']not set - Standard fields remain in body via
extract_arguments_by_destination— no regression - All existing
execute_spec.rbexamples still pass
Test strategy
Stub additional_fields_enabled? with allow(execute_instance).to receive(:additional_fields_enabled?).and_return(true/false). For merge_additional_field_arguments unit tests, call via send. For integration contexts, stub crm_http_client.request_with_auth and assert body passed to it contains additional_fields[].
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1.5 |
| QA | 0.5 |
| Total | 2 |
Assumptions:
perform_executionis complex (>50 lines); new private method adds ~35 lines; integration context needs careful stubbing; 0.5 QA for deal+ticket create with additional fields in staging.
Run to verify
bundle exec rspec spec/core/repositories/node_executions/nodes/mekari_qontak_crm/execute_spec.rb
Depends on
- Task 1 (feature flag class must exist)
Task 5: [BE] TrainAiAgent — build_non_api_args fix (Chunk 4B)
Additional fields with
use_ai: trueappear correctly in the AI training payload without crashing when no node registry entry exists for them.
Status: ✅ Actionable
Stories: QACF-S03 (runtime AI fill of additional fields requires correct training spec)
What to build
Insert early-return branch in build_non_api_args (lines 74–92 of train_ai_agent.rb): when param_value['is_additional_field'] == true, read type from param_value['type'] (not from registry_prop['type'] which would nil-crash) and set description from param_value['description'] falling back to humanized param name.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| read | app/api/frontend_service/v1/ai_agent/repositories/train_ai_agent.rb:74-92 | Confirm method body, map_param_type, depends_on_satisfied?, find_registry_properties call |
| extend | app/api/frontend_service/v1/ai_agent/repositories/train_ai_agent.rb | Add is_additional_field branch before registry_prop lookup |
| extend | spec/api/frontend_service/v1/ai_agent/repositories/train_ai_agent_spec.rb | New context for additional fields in build_non_api_args |
Implementation steps
- Read
train_ai_agent.rb:74-92— open file at those lines. Confirm method signature, whatregistry_propertieslooks like, exact line afternext unless param_value['use_ai'] == truewhere to insert the branch. - Write failing specs — extend
spec/api/frontend_service/v1/ai_agent/repositories/train_ai_agent_spec.rb. Add context'with additional field entries in parameters'. Three cases: (a)is_additional_field: true, use_ai: true, type: 'string'→type: 'str'; (b)type: 'number'→type: 'float'; (c)is_additional_field: true, use_ai: false→ NOT included. No-regression case: standard field → still resolves type from registry. Run — expect failures. - Add early branch — after
next unless param_value['use_ai'] == true, insertif param_value['is_additional_field'] == trueblock per RFC Chunk 4B. Readsparam_value['type'], callsmap_param_type, sets description viapresence || humanize, adds tohash, thennext. - Go green —
bundle exec rspec spec/api/frontend_service/v1/ai_agent/repositories/train_ai_agent_spec.rb— run full file. - Quality gate —
bundle exec rubocop app/api/frontend_service/v1/ai_agent/repositories/train_ai_agent.rb
Acceptance criteria
-
is_additional_field: true, use_ai: true, type: 'string'→{ type: 'str', description: 'Role additional field' } -
is_additional_field: true, use_ai: true, type: 'number'→type: 'float' -
is_additional_field: true, use_ai: false→ excluded - No
NoMethodErrorwhen additional field present and registry has no matching entry - Standard fields continue to use
registry_prop['type']— no regression
Test strategy
Stub find_registry_properties to return small array for standard-field cases. For additional-field cases, prove code path doesn't call into registry_prop['type']. Use expect { ... }.not_to raise_error for no-crash assertion.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| QA | 0 |
| Total | 0.5 |
Assumptions: targeted ~10-line insertion; test setup reuses existing spec scaffolding; training is internal.
Run to verify
bundle exec rspec spec/api/frontend_service/v1/ai_agent/repositories/train_ai_agent_spec.rb
Depends on
Nothing — independent.
Task 6: [BE] Node registry custom_fields_enabled seed (Chunk 5)
The FE Add-field picker shows the "Custom fields" group for create deal and create ticket actions once the registry seed is applied.
Status: ✅ Actionable
Stories: QACF-S01 (enables custom fields group in picker)
What to build
Set settings['custom_fields_enabled'] = true on qontak_crm_deal_create and qontak_crm_ticket_create NodeRegistry records via rails console or idempotent db seed. No migration required.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| read | app/models/node_registry.rb | Confirm find_by_type_and_version method signature and settings attribute name |
| run (console) | rails console / db seed | Idempotent script per RFC Chunk 5 body |
Implementation steps
- Read
node_registry.rb— confirm query method isfind_by_type_and_version(node_type)andsettingsaccessible as hash. - Confirm records exist — in rails console:
NodeRegistry.find_by_type_and_version('qontak_crm_deal_create')&.id— should return non-nil. Same forqontak_crm_ticket_create. - Run seed:
%w[qontak_crm_deal_create qontak_crm_ticket_create].each do |node_type|nr = NodeRegistry.find_by_type_and_version(node_type)next unless nrnr.settings ||= {}nr.settings['custom_fields_enabled'] = truenr.save!end
- Verify —
NodeRegistry.find_by_type_and_version('qontak_crm_deal_create').settings['custom_fields_enabled']→true. Spot-checkqontak_crm_deal_update— key must be absent.
Acceptance criteria
-
NodeRegistry.find_by_type_and_version('qontak_crm_deal_create').settings['custom_fields_enabled']→true -
NodeRegistry.find_by_type_and_version('qontak_crm_ticket_create').settings['custom_fields_enabled']→true - Other records (e.g.
qontak_crm_deal_update) —custom_fields_enabledkey absent
Test strategy
Manual verification in staging console before FE team enables Add-field picker.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| QA | 0 |
| Total | 0.5 |
Assumptions:
find_by_type_and_versionconfirmed; no schema change; idempotent one-liner.
Run to verify
rails runner "puts NodeRegistry.find_by_type_and_version('qontak_crm_deal_create').settings.inspect"
Depends on
Nothing — start parallel with Tasks 1 and 2.
Ordering rationale
- Tasks 1, 2, 6 fully parallel — no dependencies; start all three simultaneously.
- Task 3 unlocks after Task 2 —
LookupResourcescallsAdditionalFieldNormalizer.normalize. Critical path for FE picker feature (2 days total). - Task 4 unlocks after Task 1 —
ExecutecallsAiAgentActionCustomFields.enabled_for?. - Task 5 fully independent — write any time.
- Task 6 (seed) ships last — FE picker shows Custom group only when
settings['custom_fields_enabled']is true; apply in staging before FE integration testing. - Push on Task 2 first — it gates Task 3, which is the path to first user-visible behavior.
Skipped stories
No stories were blocked or excluded.
Story mapping
| Task | PRD Story | Rationale |
|---|---|---|
| Task 1 — Feature flag | QACF-S03 | Flag gates the BE merge (AC-3) |
| Task 2 — AdditionalFieldNormalizer | QACF-S01 | Normalization contract for picker (§7.1) |
| Task 3 — LookupResources | QACF-S01 | Load pipeline custom fields (API behavior #1) |
| Task 4 — Execute merge | QACF-S03 | Merge custom fields into CRM create (AC-1/2/3) |
| Task 5 — TrainAiAgent fix | QACF-S03 | AI-fill requires correct training spec |
| Task 6 — Node registry seed | QACF-S01 | Enables custom fields group in picker |