Skip to main content

CRM Actions — Custom Fields (BE) — Task Breakdown

RFC: crm-actions-custom-fields.md Mode: Vertical (one task per RFC chunk) · Scope: BE only — chatbot Rails monolith All tasks actionable — A-1 resolved 2026-07-15; no blocked tasks.


Effort Summary

TaskBE daysQA daysTotal
Task 1 — Feature flag rollout class (Chunk 1)0.500.5
Task 2 — AdditionalFieldNormalizer (Chunk 2)101
Task 3 — LookupResources extension (Chunk 3)10.51.5
Task 4 — Executemerge_additional_field_arguments (Chunk 4)1.50.52
Task 5 — TrainAiAgentbuild_non_api_args fix (Chunk 4B)0.500.5
Task 6 — Node registry seed (Chunk 5)0.500.5
Grand total516

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_id to 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

ActionFileWhat changes
readapp/core/repositories/system_preferences/rollout/crm_contact_lookup_by_ext_user_id.rbCopy class/module shape; confirm FindBy, JSON.parse, rescue [], company_id look-up
createapp/core/repositories/system_preferences/rollout/ai_agent_action_custom_fields.rbNew rollout class (code: ai_agent_action_custom_fields)
createspec/core/repositories/system_preferences/rollout/ai_agent_action_custom_fields_spec.rb3 contexts: id in list, id not in list, nil org
run (console)db seedSystemPreference.find_or_create_by! with value: '[]'

Implementation steps

  1. 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), FindBy call signature, and rescue [] pattern. Match exactly.
  2. Write failing spec (red) — create spec/core/repositories/system_preferences/rollout/ai_agent_action_custom_fields_spec.rb mirroring crm_contact_lookup_by_ext_user_id_spec.rb. Three describe '.enabled_for?' contexts: company_id in list → true; not in list → false; nil org → false. Run bundle exec rspec spec/core/repositories/system_preferences/rollout/ai_agent_action_custom_fields_spec.rb — expect 3 failures.
  3. Create the classapp/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).
  4. Go green — run spec until all 3 pass.
  5. 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 '[]'.
  6. Quality gatebundle exec rubocop app/core/repositories/system_preferences/rollout/ai_agent_action_custom_fields.rb

Acceptance criteria

  • AiAgentActionCustomFields.enabled_for?(org_with_matching_company_id) returns true
  • AiAgentActionCustomFields.enabled_for?(org_with_non_matching_company_id) returns false
  • AiAgentActionCustomFields.enabled_for?(nil) returns false
  • SystemPreference row created with value: '[]' (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

DisciplineDays
Backend0.5
QA0
Total0.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

ActionFileWhat changes
readapp/core/repositories/node_resources/mekari_qontak_crm/lookup_resources.rb:61-201Learn extract_data_from_response + filter_map pattern; confirm with_indifferent_access usage
createapp/core/repositories/node_resources/mekari_qontak_crm/additional_field_normalizer.rbFull normalizer module with FIELD_TYPE_MAP + normalize + build_dropdown_items
createspec/core/repositories/node_resources/mekari_qontak_crm/additional_field_normalizer_spec.rbUnit tests: one context per supported field_type_id + nil for unknown

Implementation steps

  1. Read lookup_resources.rb lines 61–201 — open file. Note module nesting, with_indifferent_access usage, how dropdown items handled in similar methods. Confirm no existing AdditionalFieldNormalizer or CustomFieldNormalizer in same directory.
  2. Write failing spec (red) — create spec/core/repositories/node_resources/mekari_qontak_crm/additional_field_normalizer_spec.rb. One describe '.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.
  3. Create normalizerapp/core/repositories/node_resources/mekari_qontak_crm/additional_field_normalizer.rb with RFC Chunk 2 body. Note html[:type] = config[:html_type] if config[:html_type]textarea and select must NOT have html.type key.
  4. Build dropdown itemsbuild_dropdown_items is private_class_method. For field_type_id: 2, resource_types array contains one element { type: 'fixed', items: [...] } from field[:dropdown].filter_map.
  5. Go green — run spec until all 7 contexts pass.
  6. Quality gatebundle exec rubocop app/core/repositories/node_resources/mekari_qontak_crm/additional_field_normalizer.rb

Acceptance criteria

  • field_type_id: 1html.element: 'input', html.type: 'text', is_rl: false, resource_types: nil
  • field_type_id: 2 with 3 dropdown items → html.element: 'select', no html.type, is_rl: true, resource_types: [{ type: 'fixed', items: [{name:, value:} x3] }]
  • field_type_id: 3html.type: 'number', prop_type: 'number'
  • field_type_id: 7 (percentage) → same as number
  • field_type_id: 8html.element: 'textarea', no html.type, prop_type: 'string'
  • field_type_id: 9html.type: 'url'
  • Unknown field_type_idnil
  • display_name prefers name_alias over name_locale over raw name

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

DisciplineDays
Backend1
QA0
Total1

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] LookupResourcesadditional_field_deal + additional_field_ticket (Chunk 3)

When the FE Add-field picker calls POST /v1/node-resources/lookup with resource_key: additional_field_deal or additional_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

ActionFileWhat changes
readapp/core/repositories/node_resources/mekari_qontak_crm/lookup_resources.rb:1-57Confirm RESOURCE_PATHS constant location, call dispatch structure, when indent style
readapp/core/repositories/node_resources/mekari_qontak_crm/lookup_resources.rb:61-201Confirm fetch_* private method pattern, auth_error?, extract_data_from_response, filter_map, rescue block shape
extendapp/core/repositories/node_resources/mekari_qontak_crm/lookup_resources.rbAdd 2 RESOURCE_PATHS, 2 when branches, 1 private fetch_additional_fields(resource_key)
extendspec/core/repositories/node_resources/mekari_qontak_crm/lookup_resources_spec.rbAdd contexts for resource_key: 'additional_field_deal' and 'additional_field_ticket'

Implementation steps

  1. Read lines 1–57 — open file. Note exact RESOURCE_PATHS hash position, how call uses when, whether it's case resource_key or case @resource_key. Note whether private methods are in a private block or use private def.
  2. Read lines 61–201 — pick one existing fetch method (e.g. fetch_crm_deal_pipelines). Note: does auth_error? check before or after filter_map? Does extract_data_from_response return response[] array? Does rescue catch StandardError or bare => e?
  3. Write failing specs — extend spec/core/repositories/node_resources/mekari_qontak_crm/lookup_resources_spec.rb with two new describe blocks. For each: stub crm_http_client.request_with_auth with canned CRM response (2 fields: one dropdown, one text, one field_type_id: 99), assert result has 2 items with property.is_additional_field: true. Sub-contexts: auth error, network error (→ [] + Rollbar error), unsupported type (excluded + Rollbar warning).
  4. Extend RESOURCE_PATHS — add two entries at end of hash.
  5. Extend call — add two when branches per RFC Chunk 3.
  6. Add fetch_additional_fields — new private method per RFC Chunk 3 body.
  7. Go greenbundle exec rspec spec/core/repositories/node_resources/mekari_qontak_crm/lookup_resources_spec.rb — run full file, not just new examples.
  8. Quality gatebundle exec rubocop app/core/repositories/node_resources/mekari_qontak_crm/lookup_resources.rb

Acceptance criteria

  • resource_key: 'additional_field_deal' issues GET /api/mobile/v2.8/crm/additional_fields?object=deal&created_by=user
  • resource_key: 'additional_field_ticket' issues GET /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_types populated
  • Non-dropdown: property.is_rl: false, resource_types: nil
  • Unsupported field_type_id → excluded + Rollbar.warning with resource_key, field_type_id, field_name, organization_id
  • Auth error → auth_error? path
  • Network error → [] + Rollbar.error with resource_key, organization_id
  • All existing lookup_resources_spec.rb cases 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

DisciplineDays
Backend1
QA0.5
Total1.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 (AdditionalFieldNormalizer must exist)

Task 4: [BE] Executemerge_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

ActionFileWhat changes
readapp/core/repositories/node_executions/nodes/mekari_qontak_crm/execute.rb:56-105Confirm perform_execution body — find insert point after enrich_body_for_deal_create
readapp/core/repositories/node_executions/nodes/mekari_qontak_crm/execute.rb:186-208Confirm extract_arguments_by_destination skips unregistered keys
extendapp/core/repositories/node_executions/nodes/mekari_qontak_crm/execute.rbAdd 3-line flag-gated call in perform_execution; add 2 private methods
extendspec/core/repositories/node_executions/nodes/mekari_qontak_crm/execute_spec.rbNew contexts: flag true/false, value resolution (static/AI/nil), value_name fallback, multi-field array, no-regression

Implementation steps

  1. Read execute.rb:56-105 — locate perform_execution. Find line after enrich_body_for_deal_create called and before HTTP call. That is the insert point for body = merge_additional_field_arguments(body, param_config) if additional_fields_enabled?. Confirm @organization_id and @arguments are instance vars.
  2. Read execute.rb:186-208 — confirm is_additional_field: true keys get nil from node_registry_destination_for and are skipped at line 193–194. Proves separate merge method is necessary.
  3. Write failing specs — extend spec/core/repositories/node_executions/nodes/mekari_qontak_crm/execute_spec.rb. Add describe '#perform_execution' context 'with additional fields enabled'. Also describe '#merge_additional_field_arguments' unit tests for edge cases (nil value → omitted, use_ai + resolved value, no cached_result_name → value_name = value). Run full spec file — expect new examples to fail.
  4. Add additional_fields_enabled? — new private method per RFC Chunk 4. Memoized with defined?(@additional_fields_enabled). Calls Organization.find_by(id: @organization_id) then AiAgentActionCustomFields.enabled_for?.
  5. Add merge_additional_field_arguments — new private method per RFC Chunk 4. Iterate param_config, filter_map, build { 'id', 'name', 'value', 'value_name' }. Set body['additional_fields'] = additional_items if additional_items.any?.
  6. Wire into perform_execution — insert body = merge_additional_field_arguments(body, param_config) if additional_fields_enabled? after enrich_body_for_deal_create call.
  7. Go greenbundle exec rspec spec/core/repositories/node_executions/nodes/mekari_qontak_crm/execute_spec.rb — run full file.
  8. Quality gatebundle exec rubocop app/core/repositories/node_executions/nodes/mekari_qontak_crm/execute.rb

Acceptance criteria

  • Flag true + is_additional_field: true, static value: "12", cached_result_name: "admin"body['additional_fields'] = [{ 'id' => 1, 'name' => 'role_additional_field', 'value' => '12', 'value_name' => 'admin' }]
  • Flag true + no cached_result_namevalue_name falls 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 in body['additional_fields']
  • Flag falsemerge_additional_field_arguments never called; body unchanged
  • No is_additional_field: true entries → body['additional_fields'] not set
  • Standard fields remain in body via extract_arguments_by_destination — no regression
  • All existing execute_spec.rb examples 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

DisciplineDays
Backend1.5
QA0.5
Total2

Assumptions: perform_execution is 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] TrainAiAgentbuild_non_api_args fix (Chunk 4B)

Additional fields with use_ai: true appear 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

ActionFileWhat changes
readapp/api/frontend_service/v1/ai_agent/repositories/train_ai_agent.rb:74-92Confirm method body, map_param_type, depends_on_satisfied?, find_registry_properties call
extendapp/api/frontend_service/v1/ai_agent/repositories/train_ai_agent.rbAdd is_additional_field branch before registry_prop lookup
extendspec/api/frontend_service/v1/ai_agent/repositories/train_ai_agent_spec.rbNew context for additional fields in build_non_api_args

Implementation steps

  1. Read train_ai_agent.rb:74-92 — open file at those lines. Confirm method signature, what registry_properties looks like, exact line after next unless param_value['use_ai'] == true where to insert the branch.
  2. 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.
  3. Add early branch — after next unless param_value['use_ai'] == true, insert if param_value['is_additional_field'] == true block per RFC Chunk 4B. Reads param_value['type'], calls map_param_type, sets description via presence || humanize, adds to hash, then next.
  4. Go greenbundle exec rspec spec/api/frontend_service/v1/ai_agent/repositories/train_ai_agent_spec.rb — run full file.
  5. Quality gatebundle 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 NoMethodError when 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

DisciplineDays
Backend0.5
QA0
Total0.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

ActionFileWhat changes
readapp/models/node_registry.rbConfirm find_by_type_and_version method signature and settings attribute name
run (console)rails console / db seedIdempotent script per RFC Chunk 5 body

Implementation steps

  1. Read node_registry.rb — confirm query method is find_by_type_and_version(node_type) and settings accessible as hash.
  2. Confirm records exist — in rails console: NodeRegistry.find_by_type_and_version('qontak_crm_deal_create')&.id — should return non-nil. Same for qontak_crm_ticket_create.
  3. 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 nr
    nr.settings ||= {}
    nr.settings['custom_fields_enabled'] = true
    nr.save!
    end
  4. VerifyNodeRegistry.find_by_type_and_version('qontak_crm_deal_create').settings['custom_fields_enabled']true. Spot-check qontak_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_enabled key absent

Test strategy

Manual verification in staging console before FE team enables Add-field picker.

Effort estimate

DisciplineDays
Backend0.5
QA0
Total0.5

Assumptions: find_by_type_and_version confirmed; 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 2LookupResources calls AdditionalFieldNormalizer.normalize. Critical path for FE picker feature (2 days total).
  • Task 4 unlocks after Task 1Execute calls AiAgentActionCustomFields.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

TaskPRD StoryRationale
Task 1 — Feature flagQACF-S03Flag gates the BE merge (AC-3)
Task 2 — AdditionalFieldNormalizerQACF-S01Normalization contract for picker (§7.1)
Task 3 — LookupResourcesQACF-S01Load pipeline custom fields (API behavior #1)
Task 4 — Execute mergeQACF-S03Merge custom fields into CRM create (AC-1/2/3)
Task 5 — TrainAiAgent fixQACF-S03AI-fill requires correct training spec
Task 6 — Node registry seedQACF-S01Enables custom fields group in picker