Skip to main content

Task Breakdown — Contact source field migration

Source RFC: contact-source-field-migration.md · Jira Story TF-3560 · Epic TF-3500

Mode: Vertical, one task per RFC change item — this RFC is organized as a numbered list of independently-shippable backend changes (not FE-screen-driven), so tasks mirror that structure rather than a UI-mocked/API-integration split.

Staffing (per RFC Decisions #6): Julio Jeffer owns Tasks 1–14 (contact-service Phases 0–2, this repo's actual execution surface). Ghozi owns Task 17 (Phase 3 chat-producer coordination ask). Berlianto is squad co-owner/pull-in backup. FE owner (qontak-customer-fe, Tasks 12–14) and any Mobile scope are still unconfirmed — flagged, not resolved, by this breakdown.

Repos verified locally: contact-service (Go), qontak.com (Rails), qontak-customer-fe (Vue). hub-core/hub-service/hub/hub-chat (chat producer) are not checked out in this workspace, matching the RFC's own finding — Task 17 cannot be scoped to real files and is tracked as a coordination ask only.

All file paths below were verified against the real repos (2026-07-22); where the RFC's cited path/line differed from the actual current location, the corrected path is used and the discrepancy is noted.

Effort Summary

#TaskFE daysBE daysQA daysTotal
1Phase 0 — run TF-2991 migration0.50.51
2Hoist shared SourceResolver + observability + self-healing213
3Wire resolver into Family A create/update213
4Serializer: stop preferring custom_fields duplicate0.50.51
5Re-point search-by-source filter10.51.5
6Activity-log diff: render Source as SourceName10.51.5
7OptionChangeConsumer: propagate rename to SourceName1.50.52
8CRM-origin Source referential-integrity audit (read-only)11
9Field-property reconciliation audit tooling10.51.5
10Contact backfill tooling (build, dry-run capable)31.54.5
11Run reconciliation + backfill in production (staged)112
12[FE] Contact list — display source_name10.251.25
13[FE] Contact detail panel — display source_name10.251.25
14[FE] Source filter chips — send .id10.251.25
Subtotal (contact-service + FE, this squad)3158.7526.75
15Webhook builder — remove custom_fields override (blocked, Phase 3)0.50.51
16qontak.com CRM mapper update (cross-team, blocked, external estimate)~2~1~3
17Chat producer coordination ask (cross-team, blocked, dev effort TBD)TBDTBD~0.5 (ticket only)

Confidence: medium. Tasks 1–14 (this squad's actual surface) are well-grounded — every file was verified against the real repos and the RFC's own Decisions section already resolved every [critical]/[important] open question. Confidence isn't "high" because: (a) Task 3's claim of three handlers (ContactHandler, ContactApiHandler, ContactOpenAPIHandler) was only partially verified — only ContactHandler.Update was confirmed in recon, the other two are assumed to follow the same pattern; (b) Tasks 16/17 are genuinely outside this workspace, so their effort is a rough external estimate, not a grounded one; (c) the FE owner for Tasks 12–14 is still unconfirmed per the RFC's own Decisions #6.


Task 1: [BE] Phase 0 — Run TF-2991 default-field migration against the live is_default:false bucket

Every company still on the pre-7dfa165 source field property gets it flipped to the protected default it should already be, closing the single biggest and cheapest lever in this whole plan — for free, with zero code.

Status: ✅ Actionable — endpoint already exists, confirmed not yet run.

What to build

Not code — an operational run. Query field_properties for every company_sso_id where type:"contact", name:"source", is_default:false, field_type:"dropdown_select" (no date filter), then call the existing POST /field_properties/migrate-default-fields endpoint against that list, batched.

Implementation Plan

ActionFileWhat changes
run (no code)Mongo query against field_properties collection
callinternal/app/handler/sync_field_properties_handler.go:508-546 (MigrateDefaultFieldPropertiesS2S)invoke per batch of company_sso_ids

Implementation steps

  1. Run the detection query directly against the field_properties collection (read replica if available): db.field_properties.find({type:"contact", name:"source", is_default:false, field_type:"dropdown_select"}, {company_sso_id:1, _id:0}).
  2. Guardrail: do not substitute a company-creation-date range for this query — UpdateIsDefaultByCompanyAndName (internal/app/repository/field_properties/migrate_default_fields.go:10-34) matches only on {company_sso_id, name, type:"contact"} and does not itself check is_default/field_type before flipping — a wrong input list silently flips the wrong bucket.
  3. Chunk the resulting company_sso_id list into batches of a few hundred (the service loops synchronously with two Mongo writes per company — internal/app/service/field_properties/migrate_default_fields.go:17-71, no built-in pagination).
  4. Call POST /field_properties/migrate-default-fields per batch.
  5. Re-run the detection query afterward; the remaining hit count should be ~0 (idempotent no-op for already-is_default:true companies).

Acceptance criteria

  • Detection query returns the full affected population with no date/creation-time filter.
  • Every returned company_sso_id has been passed through the migration endpoint.
  • Re-running the detection query afterward returns (near) zero hits.
  • No company_sso_id was included based on a creation-date list instead of the live query.

Test strategy

Operational verification, not unit tests: before/after counts from the detection query, spot-check a handful of migrated companies' field_properties docs for is_default:true.

Effort estimate

DisciplineDays
Backend0.5
QA0.5
Total1

Assumptions: endpoint already handles the batching logic correctly (per RFC, built same day as TF-2991); "QA" here is verification of the before/after counts, not new test code.

Run to verify

# detection query, run in mongosh against the appropriate cluster
db.field_properties.find({type:"contact", name:"source", is_default:false, field_type:"dropdown_select"}, {company_sso_id:1,_id:0}).count()

Depends on

None — highest-priority, do first.


Task 2: [BE] Hoist shared SourceResolver, add resolution-failure metric, self-healing field-property creation

Family A and Family B (chat/CRM) will apply identical source-resolution logic instead of drifting, resolution failures stop being invisible, and a missing source field property self-heals instead of hard-failing.

Status: ✅ Actionable — no external dependency, internal-only.

What to build

Extract the existing resolveSource logic out of MergeDataService into a standalone source_resolver.go service usable by both Family A (wired in Task 3) and Family B (already uses it). Add a metric on the swallowed resolution-failure error path. Make "field property not found" lazily bootstrap the property instead of failing.

Implementation Plan

ActionFileWhat changes
extend/verifyinternal/app/service/merge_data.go (package service)resolveSource func at line 1108 (RFC cited 1085-1103, moved ~10-13 lines) — extract body into new resolver
createinternal/app/service/source_resolver.gonew SourceResolver type wrapping the extracted logic
extendinternal/app/repository/field_properties/resolve.go (package fieldproperties)property-not-found handling at lines 34-36 (RFC cited resolve.go:28-36, same file but RFC's stated directory internal/app/service/field_properties/ is wrong — real path is under internal/app/repository/field_properties/) — call GenerateDefaultFields-equivalent bootstrap on not-found instead of returning an error
extendinternal/app/repository/field_properties/resolve.go:91-103reuse matchDropdownOption (confirmed exact) from the new resolver — do not duplicate matching logic
createinternal/app/service/source_resolver_test.gotable-driven tests for resolve/create/self-heal/metric paths

Implementation steps

  1. Open internal/app/service/merge_data.go and read resolveSource (line 1108) plus its caller at ResolveData lines 83-85 (internal/app/service/merge_data.go, confirmed exact) to see the current signature (func (m MergeDataService) resolveSource(ctx context.Context, companySsoID, appName, rawSource string) (source, sourceName string)) and its swallowed-error log line.
  2. Write failing tests in internal/app/service/source_resolver_test.go covering: successful resolve, auto-create-on-unmatched, resolution failure now emits a metric, and field-property- not-found triggers a bootstrap instead of erroring.
  3. Create internal/app/service/source_resolver.go with a SourceResolver type; move resolveSource's body into it verbatim first (no behavior change), then update MergeDataService to call the new type.
  4. Add the resolution-failure metric at the existing swallowed-error log line (use this repo's existing metrics client — check internal/app/service/merge_data.go imports for the pattern already used by other counters in this package).
  5. In internal/app/repository/field_properties/resolve.go:34-36, replace the not-found error return with a call to the existing GenerateDefaultFields-equivalent bootstrap (same one /init/ calls), so a first-time-seen company self-heals instead of failing every write.
  6. Run go test ./internal/app/service/... ./internal/app/repository/field_properties/... until green.
  7. Run make mocks if the extraction changes any interface Family A/B code depends on via mockery-generated mocks, then re-run tests.
  8. make lint (or repo's configured linter) and fix findings.

Acceptance criteria

  • SourceResolver produces identical output to the old inline resolveSource for existing chat/CRM traffic (regression-tested).
  • A resolution failure emits a metric (not just a log line).
  • A missing source field property triggers self-healing bootstrap instead of returning an error to the caller.
  • Existing MergeDataService tests still pass unmodified in behavior.

Test strategy

Table-driven Go tests in source_resolver_test.go covering: exact-match resolve, case- insensitive/trimmed match (reusing matchDropdownOption rules), auto-create-on-unmatched, metric emission on failure (mock the metrics client, assert call), and self-heal-on-missing- property (assert bootstrap function invoked, then resolve succeeds on retry).

Effort estimate

DisciplineDays
Backend2
QA1
Total3

Assumptions: this is primarily an extraction (low risk of behavior change) plus two additive guards; no new external dependency. Reuses matchDropdownOption rather than reimplementing.

Run to verify

go test -race -tags dynamic ./internal/app/service/... ./internal/app/repository/field_properties/...

Depends on

None. Blocks Task 3 (Family A wiring reuses this resolver) and Task 10 (backfill tooling reuses the same matching rules).


Task 3: [BE] Wire shared resolver into Family A create/update (ContactHandler, ContactApiHandler, ContactOpenAPIHandler)

A contact created or updated via the Qontak UI, S2S, or OpenAPI now gets the same ID resolution chat already gets — the raw-string-forever bug (create_contact_request.go:326) is fixed at the source for every non-chat entry point.

Status: ✅ Actionable — decided to reuse existing auto-create-if-unmatched logic, no new mode to build (see RFC Decisions #1, #3).

What to build

Call the SourceResolver (Task 2) from every Family A create/update code path instead of passing source straight through.

Implementation Plan

ActionFileWhat changes
extendinternal/app/payload/create_contact_request.go:326 (package payload; RFC's cited create_contact_request.go is actually under internal/app/payload/, not internal/app/handler/)TransformToContactObject — replace Source: e.Source, passthrough with a call into SourceResolver
extendinternal/app/handler/contact_handler.go:221 (ContactHandler.Update, RFC cited update_contact.go:246 — real file/line differ)wire resolver into the update path
verify/extendContactApiHandler, ContactOpenAPIHandler (Create/Update/SystemUpdate)[unverified — check repo]: only ContactHandler.Update was directly confirmed during recon; find the sibling handler files following the same naming pattern and apply the identical change
extendinternal/app/payload/create_contact_request_test.go, internal/app/handler/contact_handler_test.gotests asserting resolved ID+name instead of raw passthrough

Implementation steps

  1. Open internal/app/payload/create_contact_request.go and read TransformToContactObject (func starts line 255) to see how Source: e.Source (line 326) sits among the other field mappings — note the imports already present so the resolver call fits the same style.
  2. Write failing tests in create_contact_request_test.go asserting that a raw string source input resolves to {id, name} via a mocked SourceResolver.
  3. Replace the passthrough at line 326 with a call to SourceResolver.Resolve(ctx, companySsoID, rawSource), setting both Source (id) and SourceName (label) on the transformed object.
  4. Repeat the same pattern in internal/app/handler/contact_handler.go:221 (Update) and in the as-yet-unverified ContactApiHandler/ContactOpenAPIHandler Create/Update/SystemUpdate paths — grep the handler directory for files matching contact_api_handler*.go / contact_openapi_handler*.go to locate them.
  5. Run go test ./internal/app/payload/... ./internal/app/handler/... until green.
  6. make lint.

Acceptance criteria

  • A contact created via Qontak UI/S2S/OpenAPI with a raw source string now has Contact.Source = resolved dropdown ID and Contact.SourceName = resolved label.
  • An unmatched source string auto-creates a new dropdown option (same behavior as chat).
  • Update and SystemUpdate paths resolve identically to Create.
  • ContactApiHandler/ContactOpenAPIHandler locations confirmed and updated (or flagged if genuinely absent from this repo).

Test strategy

Handler/payload-level tests mocking SourceResolver, asserting the resolver is invoked with the raw input and its {id, name} output lands on the correct struct fields for Create, Update, and SystemUpdate.

Effort estimate

DisciplineDays
Backend2
QA1
Total3

Assumptions: ContactApiHandler/ContactOpenAPIHandler follow the same structural pattern as ContactHandler — if recon during implementation finds otherwise, re-estimate.

Run to verify

go test -race -tags dynamic ./internal/app/payload/... ./internal/app/handler/...

Depends on

Task 2 (shared SourceResolver must exist first).


Task 4: [BE] Serializer — stop preferring the custom_fields duplicate

The API response for every Family A contact now shows the correctly-resolved Contact.Source instead of the redundant, never-resolved custom_fields copy — this is the actual visible symptom from the original bug report.

Status: ✅ Actionable.

What to build

Remove getSourceFromCustomFields()'s precedence over Contact.Source in the response serializer.

Implementation Plan

ActionFileWhat changes
extendinternal/app/repository/contact/create_serializer.go:210-225 (package contact; RFC cited create_serializer.go without the internal/app/repository/ prefix — confirmed real path)getSourceFromCustomFields() — stop this function's output from overriding Contact.Source in the serialized response
extendsame file, lines 341, 661, 724 (all three usage sites confirmed exact)update each call site to prefer Contact.Source/Contact.SourceName
extendinternal/app/repository/contact/create_serializer_test.gotests asserting response source key matches Contact.Source, not the custom_fields copy

Implementation steps

  1. Open internal/app/repository/contact/create_serializer.go and read getSourceFromCustomFields() (lines 210-225) plus its three call sites (341, 661, 724) to see the exact precedence logic being removed.
  2. Write failing tests asserting: a contact with Contact.Source="abc123" and a stale/absent custom_fields["source"] still serializes source: "abc123" in the response.
  3. At each of the three call sites, remove the custom_fields-first branch; fall back to custom_fields only if Contact.Source is empty (State F handling, promoted properly by Task 10's backfill and get_contact.go's existing fallback).
  4. Run go test ./internal/app/repository/contact/... until green.

Acceptance criteria

  • API response source field matches Contact.Source, not the custom_fields duplicate, for all three serialization paths (341, 661, 724).
  • A contact with no custom_fields["source"] entry still serializes correctly (no regression for contacts lacking the duplicate).

Test strategy

Table-driven tests per call site: (a) Contact.Source set + stale custom_fields copy → uses Contact.Source; (b) Contact.Source empty + custom_fields present → falls back; (c) neither present → empty string, no panic.

Effort estimate

DisciplineDays
Backend0.5
QA0.5
Total1

Assumptions: pure precedence-order change, no new field additions.

Run to verify

go test -race -tags dynamic ./internal/app/repository/contact/...

Depends on

None directly, but should land in the same release train as Task 3 (per RFC sequencing — avoid freshly-resolved IDs displaying inconsistently against a serializer that hasn't caught up).


Task 5: [BE] Re-point search-by-source filter at Contact.Source

Contacts created without the redundant custom_fields duplicate (effectively all Family-A contacts today) become searchable/segmentable by source, closing a silent, currently-invisible gap.

Status: ✅ Actionable.

What to build

Change the search filter query to match Contact.Source instead of custom_fields.value.

Implementation Plan

ActionFileWhat changes
extendinternal/app/payload/search_contact_request.go:264-277 (package payload; RFC cited under internal/app/handler/ — real path confirmed under internal/app/payload/)replace bson.M{"custom_fields.key":"source","custom_fields.value":src} with a filter on Contact.Source (the resolved ID)
extendinternal/app/payload/search_contact_request_test.gotests asserting the new filter shape

Implementation steps

  1. Open internal/app/payload/search_contact_request.go and read the current filter-building logic at lines 264-277.
  2. Write failing tests asserting a search-by-source request builds a bson.M keyed on the top-level source field (matching an ID), not custom_fields.key/custom_fields.value.
  3. Replace the filter construction accordingly. Confirm the FE now sends the option .id (Task 14) rather than .name — this filter only becomes correct once both sides ship together (RFC sequencing constraint).
  4. Run go test ./internal/app/payload/... until green.

Acceptance criteria

  • Search-by-source matches contacts via Contact.Source (ID), independent of whether a custom_fields["source"] duplicate exists.
  • Existing custom_fields-only contacts (pre-backfill) are handled per the RFC's documented state — flagged, not silently dropped, until Task 11's backfill completes.

Test strategy

Assert the constructed Mongo filter shape directly (no DB integration needed) for a given input ID.

Effort estimate

DisciplineDays
Backend1
QA0.5
Total1.5

Assumptions: FE sends .id in the same release train (Task 14) — ships together per RFC's explicit sequencing constraint, not staggered.

Run to verify

go test -race -tags dynamic ./internal/app/payload/...

Depends on

Must ship in the same release train as Task 14 (FE filter chips) — see RFC "Sequencing constraint" note.


Task 6: [BE] Activity-log diff — render Source as SourceName

A human reviewing a contact's change history sees "Source changed from Whatsapp to Instagram" instead of two unreadable UUIDs, once Family A also resolves to IDs (Task 3).

Status: ✅ Actionable.

What to build

Add Source to (or special-case it in) the activity-log diff renderer so it displays the resolved SourceName instead of the raw ID.

Implementation Plan

ActionFileWhat changes
extendinternal/app/service/merge_data.goGenerateChanges, skipFieldNames-adjacent logic at lines 816-845 (RFC cited 790-820, moved ~25 lines)special-case Source to diff/display SourceName values
extendinternal/app/service/update_contact.goGenerateChanges, skipFieldNames map at lines 309-336 (RFC cited update_contact.go:306-324, close match, different layer than RFC's handler citation)same special-case for the update path
extendcorresponding _test.go files in both packagesassert diff renders label, not ID

Implementation steps

  1. Open internal/app/service/merge_data.go and read GenerateChanges (func at line 813) to see how skipFieldNames currently excludes fields from the diff, and how included fields are rendered.
  2. Write failing tests asserting a Source change renders using SourceName values, not raw Source IDs.
  3. Add a special case (not a skip) for Source: when diffing, substitute the before/after SourceName for display purposes while still keying the change on Source.
  4. Repeat in internal/app/service/update_contact.go:309-336.
  5. Run go test ./internal/app/service/... until green.

Acceptance criteria

  • Activity-log entries for a Source change display human-readable names, not UUIDs.
  • The underlying stored diff still correctly identifies which field changed (Source), only the rendered value differs.

Test strategy

Assert GenerateChanges output for a synthetic before/after contact pair with differing Source/SourceName, checking the rendered diff string uses the name values.

Effort estimate

DisciplineDays
Backend1
QA0.5
Total1.5

Assumptions: diff renderer already has a hook point for per-field custom formatting (used by similar special-cased fields elsewhere in the same function) — no new rendering framework needed.

Run to verify

go test -race -tags dynamic ./internal/app/service/...

Depends on

Best done after Task 3 (Family A resolving to IDs) so the readability problem this fixes is actually present in the audit trail being tested — but not a hard code dependency.


Task 7: [BE] OptionChangeConsumer — propagate dropdown rename into persisted Contact.SourceName

A dropdown rename (e.g. "Whatsapp" → "WhatsApp Business") no longer leaves every chat/CRM-origin contact's persisted SourceName silently stale — the RFC decided to persist SourceName going forward (Decisions #4), so this consumer must now also own its staleness.

Status: ✅ Actionable.

What to build

Extend OptionChangeConsumer to also rewrite the system Contact.SourceName field on rename, not just custom_fields entries.

Implementation Plan

ActionFileWhat changes
extendinternal/app/consumer/option_change.gohandleRenamedOption/handleDropdownRename at lines 111-127 (RFC cited ~105-127, close match)add a bulk update of Contact.SourceName for every contact whose Contact.Source matches the renamed option ID, alongside the existing custom_fields rewrite
verifyinternal/app/service/get_contact.go:87-98 (confirmed exact — SourceName/ID population)confirm no live-resolve-only path needs removal now that persistence is authoritative
extendinternal/app/consumer/option_change_test.gotests asserting Contact.SourceName is rewritten on rename, alongside the existing custom_fields assertion

Implementation steps

  1. Open internal/app/consumer/option_change.go and read handleRenamedOption/ handleDropdownRename (lines 104-127) to see exactly how the existing custom_fields rewrite is batched (per-company, per-option).
  2. Write failing tests asserting that after a rename event, contacts with Contact.Source == <renamed option ID> get Contact.SourceName updated to the new label.
  3. Add a bulk UpdateMany/bulk-write alongside the existing custom_fields rewrite, scoped to {company_sso_id, source: <optionID>}, setting source_name: <newLabel>.
  4. Confirm get_contact.go:87-98's existing SourceName population logic doesn't double-write or conflict with this consumer's async update (read path should just read the persisted value, not re-resolve).
  5. Run go test ./internal/app/consumer/... until green.

Acceptance criteria

  • Renaming a dropdown option updates every affected contact's persisted Contact.SourceName, not just its custom_fields copy.
  • This runs as part of the same consumer batch as the existing custom_fields rewrite (no new consumer/queue needed).
  • No regression to the existing custom_fields rename behavior.

Test strategy

Consumer-level test firing a synthetic rename event, asserting both the pre-existing custom_fields rewrite and the new Contact.SourceName bulk update both occur and target the correct contact set.

Effort estimate

DisciplineDays
Backend1.5
QA0.5
Total2

Assumptions: reuses the same batching/update mechanism already present in this consumer for custom_fields — not a new pipeline.

Run to verify

go test -race -tags dynamic ./internal/app/consumer/...

Depends on

Should land before or alongside Task 3 (once more contacts have Contact.Source as an ID, this propagation matters for all of them, not just chat/CRM-origin contacts).


Task 8: [BE] CRM-origin Source referential-integrity audit (read-only)

Surfaces which CRM-sent Contact.Source values (an opaque ID CRM minted independently) don't actually match an entry in the company's field_properties["source"].dropdown — visibility only, since there's no "correct" value to rewrite to on mismatch.

Status: ✅ Actionable — read-only, no external dependency.

What to build

A one-off (or repeatable) audit query/report comparing CRM-origin Contact.Source values against each company's live dropdown options, flagging mismatches.

Implementation Plan

ActionFileWhat changes
createinternal/app/handler/ — new handler under the existing BasicAuth-protected /api/v1 admin surface (see internal/server/rest_router.go:296-394)new read-only audit endpoint or script, output-only

Implementation steps

  1. Open internal/server/rest_router.go:296-394 to see the existing /api/v1 route group pattern (BasicAuth + ContextLogger + tracing middleware; e.g. sync_field_properties_handler.go's migrate-default-fields route as a reference handler).
  2. Write a read-only handler/script that, per company, loads field_properties["source"].dropdown and cross-checks every CRM-origin contact's Contact.Source against the dropdown's option IDs, emitting a mismatch report (company, contact ID, stored value).
  3. Add it as a new sub-route under /api/v1/contacts/audit/source_referential_integrity or equivalent, following the existing /api/v1 BasicAuth-protected handler pattern.
  4. Test against a seeded set of matching/mismatching contacts.

Acceptance criteria

  • Report lists every CRM-origin contact whose Contact.Source has no matching dropdown option, per company.
  • No writes occur — audit only.

Test strategy

Unit test the mismatch-detection logic with seeded matching/non-matching fixture data; no live DB integration required for correctness.

Effort estimate

DisciplineDays
Backend1
Total1

Assumptions: follows the existing BasicAuth-protected /api/v1 admin-endpoint pattern; no QA line since this is an internal read-only tool, not user-facing behavior.

Run to verify

go test -race -tags dynamic ./internal/app/handler/...

Depends on

None.


Task 9: [BE] Field-property reconciliation audit tooling (missing / incompatible buckets)

Beyond the is_default:false + dropdown_select bucket Task 1 already fixes for free, this finds the two rarer buckets — companies where the source field property is entirely missing, or where an admin's own unrelated custom field happens to be named source — so they get bootstrapped or manually reconciled instead of silently left out.

Status: ✅ Actionable.

What to build

A query/report tool distinguishing the three field-property states per company: canonical (is_default:true), adoptable (is_default:false + dropdown_select, Task 1's bucket), missing (no doc), and incompatible (wrong field_type).

Implementation Plan

ActionFileWhat changes
createadmin script or BasicAuth-protected /api/v1-style endpointruns the field-property state query per company, outputs the four buckets

Implementation steps

  1. Build the detection query from the RFC's own spec: db.field_properties.find({type:"contact", name:"source"}, {company_sso_id:1, is_default:1, field_type:1, dropdown:1}).
  2. Classify each company into: canonical / adoptable (Task 1's target) / missing / incompatible, per the RFC's decision tree.
  3. For "missing", call the self-healing bootstrap from Task 2 (or the equivalent GenerateDefaultFields call directly) to create the property fresh.
  4. For "incompatible", output a manual-review list (rename + product/CS sign-off, not automated).
  5. Test the classification logic against seeded fixture documents covering all four states.

Acceptance criteria

  • Every company in the live field_properties collection is classified into exactly one of the four buckets.
  • "Missing" bucket companies are bootstrapped via the self-healing path.
  • "Incompatible" bucket is reported for manual handling, not auto-modified.

Test strategy

Classification-logic unit tests against seeded fixture documents for each of the four states.

Effort estimate

DisciplineDays
Backend1
QA0.5
Total1.5

Assumptions: reuses Task 2's self-healing bootstrap rather than reimplementing GenerateDefaultFields logic.

Run to verify

go test -race -tags dynamic ./internal/app/repository/field_properties/...

Depends on

Task 2 (self-healing bootstrap) for the "missing" bucket resolution. Must be confirmed complete before Task 11 runs the contact backfill (RFC: "field-property reconciliation must be confirmed complete before the contact backfill runs").


Task 10: [BE] Contact backfill tooling — build (dry-run capable, idempotent)

The tooling that will resolve every existing chat/Family-A/legacy contact's raw-string Source into a proper dropdown ID — built and tested here, not yet run (Task 11 runs it, after the read-path fixes are live).

Status: ✅ Actionable to build; execution is gated (see Task 11).

What to build

An admin migration entry point under the existing BasicAuth-protected /api/v1 surface that, per company, builds a name→ID map from field_properties["source"].dropdown, then bulk-resolves contacts in batches, following the State A–F handling described in the RFC.

Implementation Plan

ActionFileWhat changes
createinternal/app/handler/ (new handler, /api/v1 BasicAuth pattern per rest_router.go:296-394)new backfill admin endpoint, dry-run + live modes
reuseinternal/app/repository/field_properties/resolve.go:91-103 (matchDropdownOption)reuse matching rules rather than reimplementing
reuseTask 2's SourceResolver/self-heal logic where applicableconsistent resolution semantics with live traffic

Implementation steps

  1. Open internal/server/rest_router.go:296-394 and the reference handlers it names (e.g. sync_field_properties_handler.go's migrate-default-fields route) to match this repo's existing /api/v1 BasicAuth admin-migration-endpoint conventions (request shape, batching, reporting).
  2. Write failing tests for: batch-by-company map building, case-insensitive/trimmed matching (reusing matchDropdownOption), State F promotion (custom_fields["source"]Source before resolving), State D audit-only handling (flag, don't rewrite), and the MaxDropdownItems (150, internal/app/repository/field_properties/base.go:335) pre-flight halt-and-flag behavior.
  3. Implement: per company, fetch field_properties["source"].dropdown once, build an in-memory map, then bulk-$set contacts in batches (e.g. 500) via UpdateMany/bulk-write — no per-contact serial resolver calls.
  4. Add dry-run mode (report counts/samples, no writes) and idempotency (skip contacts whose Source already matches an existing option ID).
  5. Add the pre-flight MaxDropdownItems cap check — halt and flag the company for manual review rather than let per-contact resolution start failing mid-run.
  6. Add the aggregate-report shape matching migrate_default_fields.go's existing per-company success/failure/total-updated pattern.
  7. Run go test ./internal/app/handler/... ./internal/app/service/... until green.

Acceptance criteria

  • Dry-run mode reports accurate counts/samples with zero writes.
  • Live mode is idempotent — re-running is a no-op for already-migrated contacts.
  • State F contacts (Source empty, custom_fields["source"] present) are promoted then resolved.
  • State D contacts (CRM-origin) are audit-flagged, never rewritten.
  • A company that would exceed MaxDropdownItems (150) halts and is flagged, not partially processed.
  • Report output matches the existing migrate_default_fields.go aggregate shape.

Test strategy

Table-driven tests per state (A–F) using seeded fixture contacts and field-property docs; a separate test asserting the MaxDropdownItems pre-flight halt fires correctly at the boundary.

Effort estimate

DisciplineDays
Backend3
QA1.5
Total4.5

Assumptions: reuses matchDropdownOption and the SourceResolver's matching semantics rather than building new matching logic; this is the single largest task in the plan, consistent with the RFC calling backfill "highest blast radius."

Run to verify

go test -race -tags dynamic ./internal/app/handler/... ./internal/app/service/...

Depends on

Task 2 (reuses matching logic), Task 9 (field-property reconciliation must be classified first). Blocks Task 11 (production run).


Task 11: [BE] Run field-property reconciliation + contact backfill in production (staged rollout)

The full migration actually executes against production data — pilot first, then expand in batches, with a mandatory pre-run backup, closing the "search is silently incomplete" and "downstream CRM fragmentation" problems for the existing contact population.

Status: ⚠️ Partially blocked — must wait until the read-path fixes (Tasks 3, 4, 5) are deployed and Family A resolution is live; running earlier means freshly-backfilled contacts get re-broken by not-yet-updated read paths, and Family A keeps writing new raw-string contacts that immediately re-populate the "needs backfill" bucket.

What to build

Not new code — the production execution of Task 9's reconciliation and Task 10's backfill tooling, staged (pilot → expand) with a snapshot/backup step first.

Implementation Plan

ActionFileWhat changes
runTask 9's toolingfield-property reconciliation, per company
runTask 10's toolingcontact backfill, staged batches
opsmongodump or subset snapshot before first production write

Implementation steps

  1. Confirm Tasks 3, 4, 5 are deployed and stable (read paths + Family A resolution live).
  2. Snapshot/backup the affected contacts subset (or full mongodump) — this is the rollback path since there's no cheap partial-undo once IDs are written.
  3. Run Task 9's field-property reconciliation to completion first — the backfill resolver is only as correct as the field property it resolves against.
  4. Pilot the contact backfill (Task 10, live mode) on low-risk/internal companies first; verify end-to-end (backend response + FE display) manually.
  5. Expand to the full population in batches, monitoring the aggregate report each batch.
  6. Confirm search-by-source (Task 5) and activity-log rendering (Task 6) look correct against a sample of newly-backfilled contacts.

Acceptance criteria

  • Pre-run backup/snapshot exists and is confirmed restorable.
  • Field-property reconciliation (Task 9) shows ~0 remaining "adoptable"/"missing" companies.
  • Pilot batch verified end-to-end (backend + FE) before full expansion.
  • Full population backfill completes with an aggregate success/failure report.

Test strategy

Operational verification against the pilot batch, then spot-checks per expansion batch — not new unit tests (Task 10 already covers logic correctness).

Effort estimate

DisciplineDays
Backend1
QA1
Total2

Assumptions: Task 10's tooling is already tested and correct; this task's effort is the operational rollout (pilot, monitor, expand), not new development.

Run to verify

Manual staged rollout — no single automated command; verify via Task 9/10's own reporting output per batch.

Depends on

Tasks 1, 3, 4, 5, 9, 10 all complete and deployed.


Task 12: [FE] Contact list — display source_name instead of source

A user viewing the contact list sees a readable source label ("Whatsapp") in the Source column instead of a raw ID, once the backend persists source_name.

Status: ✅ Actionable (independent of backend timing for the display change itself, but should ship in the same release train as Tasks 3 and 5 per the RFC's sequencing constraint).

Design reference: n/a — cosmetic data-source swap on an existing column, no new UI/design; existing Figma frames for the contact list still apply.

What to build

Switch the Source column's data binding from customer.source to customer.source_name.

Implementation Plan

ActionFileWhat changes
extendfeatures/customers/views/ListPage.vue (confirmed path; RFC's cited lines 206/479-504 are close — actual source column def is line 213, fetchList()/getSelectedSourcesCode() at 479-504 confirmed)source column definition switches to reading source_name for display
extendfeatures/customers/views/components/ListTable.vueformatCustomerData (confirmed, function at line 343, RFC cited 337-348)display formatting uses source_name
extendfeatures/customers/views/ListPage.spec.ts, features/customers/views/components/ListTable.spec.ts (colocated .spec.ts, confirmed convention)assert source_name is what's rendered

Implementation steps

  1. Open features/customers/views/ListPage.vue and read the column definition around line 213 ({ name: 'Source', id: 'source', ... }) to see how other columns are structured.
  2. Write failing tests in ListTable.spec.ts asserting formatCustomerData renders customer.source_name, not customer.source.
  3. Update the column definition/formatter to read source_name.
  4. Run pnpm test -- features/customers/views/components/ListTable.spec.ts until green.
  5. pnpm lint && pnpm build.

Acceptance criteria

  • Contact list Source column displays the resolved label (source_name), not the raw ID.
  • A contact with an empty source_name (not yet backfilled) degrades gracefully (empty cell, no crash).

Test strategy

ListTable.spec.ts asserts formatCustomerData output uses source_name; a companion case covers an empty/undefined source_name rendering as blank rather than throwing.

Effort estimate

DisciplineDays
Frontend1
QA0.25
Total1.25

Assumptions: source_name is already present on the API response by the time this ships (depends on Tasks 2/3/4 landing first); no new API call needed on the FE side.

Run to verify

pnpm test -- features/customers/views/components/ListTable.spec.ts && pnpm lint

Depends on

Backend Tasks 2, 3, 4 (SourceName must be persisted and served before this has anything correct to display).


Task 13: [FE] Contact detail panel — display source_name, keep source for edit-mode dropdown

A user viewing a contact's detail panel sees the readable source label; a user editing the contact still sees/selects by ID in the dropdown, matching how every other is_default field already behaves except for this one.

Status: ✅ Actionable.

Design reference: n/a — cosmetic data-source swap, no new UI/design.

What to build

getPropertyValue()'s generic is_default-field fallback currently reads the top-level field directly for display; special-case source to read source_name for display while the edit-mode v-model keeps binding to source (ID).

Implementation Plan

ActionFileWhat changes
extendfeatures/customers/detail/components/CustomerDetails.vuegetPropertyValue() (confirmed, function at lines 616-641; RFC cited 613-638, off by ~3)add a source-specific branch: display source_name, keep source bound for edit mode
extendfeatures/customers/detail/components/CustomerDetails.spec.ts (colocated convention)assert display vs. edit-mode value split

Implementation steps

  1. Open features/customers/detail/components/CustomerDetails.vue and read getPropertyValue() (lines 616-641) to see the existing is_default fallback branch this special-cases against.
  2. Write failing tests asserting: display mode shows source_name; edit-mode v-model still binds to source (ID) so the dropdown selection logic is unaffected.
  3. Add the source-specific branch in getPropertyValue().
  4. Run pnpm test -- features/customers/detail/components/CustomerDetails.spec.ts until green.
  5. pnpm lint && pnpm build.

Acceptance criteria

  • Detail panel (view mode) shows the resolved label, not the raw ID.
  • Edit-mode dropdown still correctly selects/saves by ID (source), unaffected by the display change.

Test strategy

Component test asserting view-mode renders source_name and edit-mode v-model value equals source (ID), using a fixture contact with distinct id/name values to catch any accidental swap.

Effort estimate

DisciplineDays
Frontend1
QA0.25
Total1.25

Assumptions: the edit-mode dropdown component itself is unchanged — only the display-mode branch of getPropertyValue() is touched.

Run to verify

pnpm test -- features/customers/detail/components/CustomerDetails.spec.ts && pnpm lint

Depends on

Backend Tasks 2, 3, 4 (same as Task 12).


Task 14: [FE] Source filter chips — send .id instead of .name

Filtering the contact list by source now matches the backend's re-pointed filter (Task 5) correctly, instead of silently under-matching contacts whose stored value isn't the exact display name.

Status: ✅ Actionable — must ship in the same release train as backend Task 5 (RFC's explicit sequencing constraint; flipping only one side breaks the other).

Design reference: n/a — no visual change, only the underlying query param value changes.

What to build

getSelectedSourcesCode() (and the onSourceChange path) currently send .name; switch to .id, matching what fetchSourceOptions() already computes alongside .name.

Implementation Plan

ActionFileWhat changes
extendfeatures/customers/views/ListPage.vuegetSelectedSourcesCode() (confirmed exact, lines 443-446; RFC cited ~429-433, corrected during recon)switch .map((source) => source.name) to .map((source) => source.id)
verifysame file — fetchSourceOptions() (confirmed, starts line 659; RFC cited 638-671, close)confirm it already exposes .id per option (it does) — no change needed here, just the consumer
extendfeatures/customers/views/ListPage.spec.tsassert the query param sent is .id, not .name

Implementation steps

  1. Open features/customers/views/ListPage.vue and read getSelectedSourcesCode() at lines 443-446 — confirm it currently maps .name for both the "all selected" and specific-selection branches, sent as the 'source[]' param (line 472).
  2. Write failing tests asserting the function returns .id values, not .name, for both branches.
  3. Change both .map((source) => source.name) calls (lines 445-446) to .map((source) => source.id).
  4. Run pnpm test -- features/customers/views/ListPage.spec.ts until green.
  5. pnpm lint && pnpm build.

Acceptance criteria

  • Selecting a source filter chip sends the option's .id as the source[] query param, not its .name.
  • The "all sources selected" branch also sends IDs, not names.

Test strategy

Unit test getSelectedSourcesCode() directly with a fixture sourceLists/selectedSource, asserting the returned array is IDs.

Effort estimate

DisciplineDays
Frontend1
QA0.25
Total1.25

Assumptions: fetchSourceOptions() already computes .id alongside .name (confirmed during recon) — this task only changes the consumer, not the data-fetching function.

Run to verify

pnpm test -- features/customers/views/ListPage.spec.ts && pnpm lint

Depends on

Must ship in the same release train as backend Task 5.


Task 15 (🚫 Blocked, Phase 3): [BE] Webhook builder — remove custom_fields override

External webhook subscribers (currently receiving the raw label instead of the ID) start getting the correctly-resolved source — but only once qontak.com stops depending on the old behavior, or this silently breaks their CRM sync (creates garbage Crm::Source rows named after UUIDs).

Status: 🚫 Blocked — do not ship ahead of qontak.com's Contact360::ParamsMapper / CdpIncomingContactMapper update (Task 16) reaching production and stabilizing.

What to build

Remove the same custom_fields-preference override from the outbound webhook payload builder.

Implementation Plan

ActionFileWhat changes
extendinternal/app/service/webhook_delivery_interface.go:855-867 (confirmed exact — if cf.Key == "source" { filteredPayload["source"] = strValue })remove this override so the webhook payload uses Contact.Source/Contact.SourceName directly

Implementation steps

  1. Confirm Task 16 (qontak.com) has shipped and Task 11's backfill has baked for the RFC's suggested 2-4 weeks before starting this task.
  2. Open internal/app/service/webhook_delivery_interface.go:855-867 and read the exact override being removed.
  3. Write a test asserting the webhook payload's source key now matches Contact.Source directly, with no custom_fields override.
  4. Remove the override block.
  5. Run go test ./internal/app/service/....

Acceptance criteria

  • Outbound webhook source field matches Contact.Source (ID), not the custom_fields label.
  • Confirmed via qontak.com's team that their mapper update (Task 16) is live in production first.

Test strategy

Assert webhook payload construction directly against a fixture contact with differing Contact.Source and stale custom_fields["source"] values.

Effort estimate

DisciplineDays
Backend0.5
QA0.5
Total1

Assumptions: trivial code change once unblocked — the entire cost of this task is the cross-team wait, not the implementation.

Run to verify

go test -race -tags dynamic ./internal/app/service/...

Depends on

Task 16 (qontak.com update) live in production, plus a 2-4 week bake period on Task 11's backfill.


Task 16 (🚫 Blocked, cross-team): qontak.com — update Contact360::ParamsMapper / CdpIncomingContactMapper

qontak.com's legacy CRM module stops minting/matching Crm::Source rows by a raw label pulled from custom_fields/top-level source, and instead reads source_name for the display value while populating Crm::Source.cdp_option_id from the now-reliable source (ID) — closing the loop with the CRM→CDP direction, which already sends cdp_option_id today.

Status: 🚫 Blocked — not this repo's execution surface; needs its own ticket, owner, and review from whoever maintains qontak.com's Contact360/CRM module. Not staffed by this squad (per RFC Decisions #6, only Ghozi's Phase 3 chat-producer ask is staffed from this side — qontak.com ownership is unresolved).

What to build (owned externally — grounded here for handoff clarity)

Verified real files in qontak.com (Rails):

ActionFileWhat changes
extendapp/services/contact360/params_mapper.rb#find_or_create_source (confirmed exact, lines 79-85; currently Crm::Source.find_or_create_by(source: source, team_id: @team.id)&.id)stop reading custom_fields["source"]/top-level source as the display label; read source_name instead
extendapp/services/contact360/cdp_incoming_contact_mapper.rb#source_status_attrs (confirmed exact, lines 153-167; currently Crm::Source.find_or_create_by(source: source_value, team_id: @team_id)&.id)same change — consume source_name for the Crm::Source.source display value
verifyapp/services/crm/centralized_contacts/params_mapper.rb:55 (confirmed exact — already sends @lead.crm_source&.cdp_option_id)no change needed — CRM→CDP direction already correct
contextapp/models/crm/source.rb (confirmed — find_or_create_by(source:, team_id:), uniqueness scoped to team_id)populate Crm::Source.cdp_option_id from the reliable source (ID) field going forward

Acceptance criteria

  • find_or_create_source and source_status_attrs both consume source_name for the Crm::Source display value, once contact-service's webhook reliably sends it.
  • Crm::Source.cdp_option_id is populated from contact-service's source (ID) field.
  • No new garbage Crm::Source rows created from raw UUID strings during the transition.

Effort estimate (external — rough estimate only, not this squad's velocity)

DisciplineDays
Backend (qontak.com team)~2
QA (qontak.com team)~1
Total~3

This estimate is directional only — grounded in the two verified Ruby methods above, but actual sizing is qontak.com's team's call, not this squad's.

Depends on

Task 3 (Family A resolving to real IDs) and Task 4 (serializer fix) landing and stabilizing first — qontak.com needs source_name reliably present in the webhook payload before it can switch.


Task 17 (🚫 Blocked, cross-team): File a ticket — ask chat producer to stop duplicating source into custom_fields

Once contact-service's read paths (Tasks 4, 5, 6) are live and stable through a bake period, the chat/omnichannel producer team removes the now-unnecessary custom_fields["source"] duplicate it currently sends — expected to be straightforward since the RFC's Decisions confirm this duplication is incidental, not defensive.

Status: 🚫 Blocked — the producer code (hub-core/hub-service/hub/hub-chat) is not checked out in this workspace; this task is a cross-team coordination ask, not a code change this squad can make. Owned by Ghozi per RFC Decisions #6.

What to build

Not code — file a cross-team ticket against the chat/omnichannel producer team asking them to remove the custom_fields: [{key:"source", ...}] duplication from the app_name:"chat" payload.

Implementation steps

  1. Confirm Tasks 4, 5, 6 have been live and stable through a bake period (per RFC: do not ask before this, or it breaks qontak.com's CRM module and any not-yet-migrated read path silently).
  2. File the ticket with the producer team (owner: Ghozi), referencing this RFC and the specific payload shape to remove.
  3. Track the producer team's own timeline/ticket for the actual removal — outside this repo's control.

Acceptance criteria

  • Ticket filed with the chat/omnichannel producer team, referencing the specific custom_fields["source"] duplication to remove.
  • Not filed before contact-service's read-path fixes have baked.

Effort estimate

DisciplineDays
Coordination (ticket-filing only)~0.5
Producer-team dev effortTBD — owned by another team, not sized here

Depends on

Tasks 4, 5, 6 live and stable through a bake period.


Ordering rationale

  • Task 1 is the free win — do it before any code ships. It's already-built, zero-risk, and every day it's delayed adds more contacts to the (much more expensive) Task 10/11 backfill population.
  • Task 2 gates Task 3. The shared resolver must exist before Family A can be wired to it — building it as an extraction first (not a rewrite) keeps chat/CRM behavior unchanged while Task 3 is in flight.
  • Tasks 4, 5, 6, 7, 8 are independent of each other and of Tasks 2/3 — they can run in parallel once resourcing allows; none blocks another except where noted (Task 5 ↔ Task 14 must ship together, per the RFC's explicit sequencing constraint).
  • Task 9 must complete before Task 10's tooling is trusted, and both must complete before Task 11 runs — the RFC is explicit that backfilling against an unreconciled field property produces wrong results. This is the critical path's most expensive leg (Task 10 alone is 4.5 days, the single largest task in the plan).
  • Task 11 is deliberately last among contact-service tasks — running it before the read-path fixes (Tasks 3, 4, 5) are deployed turns the backfill into a moving target, per the RFC's own sequencing note.
  • Tasks 15, 16, 17 are the actual critical-path risk — they depend on external teams whose timelines this squad doesn't control. Push on filing Task 16's ticket early (it can start independently once Tasks 3/4 are stable) since it's the longer of the two external asks and gates Task 15.
  • FE Tasks 12-14 are cheap and mostly parallelizable with backend work, but Task 14 has a hard release-train coupling to Task 5 — don't let these ship independently.

Skipped stories

No stories were fully excluded from the breakdown — every RFC change item (contact-service items 1-11, both qontak.com methods, the chat-producer ask, and all four FE items) is represented above, either as an actionable task or an explicitly blocked/tracked one, per the "include cross-team items as blocked/tracked tasks" scope decision. The RFC's two remaining [nice-to-have] Open Questions (Contact.SourceID deprecation, and this bucket's Class-of-Service call) are process/decision items, not implementation tasks, and are intentionally not represented as tasks here — they need a product/planning decision, not code.