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 — Execute — merge_additional_field_arguments (Chunk 4)1.50.52
Task 5 — TrainAiAgent — build_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 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).
  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 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) 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 normalizer — app/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 items — build_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 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: 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: 3 → html.type: 'number', prop_type: 'number'
  • field_type_id: 7 (percentage) → same as number
  • field_type_id: 8 → html.element: 'textarea', no html.type, prop_type: 'string'
  • field_type_id: 9 → html.type: 'url'
  • Unknown field_type_id → nil
  • 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] LookupResources — additional_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 green — bundle exec rspec spec/core/repositories/node_resources/mekari_qontak_crm/lookup_resources_spec.rb — run full file, not just new examples.
  8. Quality gate — bundle 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] 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​

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 green — bundle exec rspec spec/core/repositories/node_executions/nodes/mekari_qontak_crm/execute_spec.rb — run full file.
  8. Quality gate — bundle 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_name → value_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 false → merge_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] TrainAiAgent — build_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 green — bundle exec rspec spec/api/frontend_service/v1/ai_agent/repositories/train_ai_agent_spec.rb — run full file.
  5. 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 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. Verify — NodeRegistry.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 2 — LookupResources calls AdditionalFieldNormalizer.normalize. Critical path for FE picker feature (2 days total).
  • Task 4 unlocks after Task 1 — Execute 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