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
| # | Task | FE days | BE days | QA days | Total |
|---|---|---|---|---|---|
| 1 | Phase 0 — run TF-2991 migration | — | 0.5 | 0.5 | 1 |
| 2 | Hoist shared SourceResolver + observability + self-healing | — | 2 | 1 | 3 |
| 3 | Wire resolver into Family A create/update | — | 2 | 1 | 3 |
| 4 | Serializer: stop preferring custom_fields duplicate | — | 0.5 | 0.5 | 1 |
| 5 | Re-point search-by-source filter | — | 1 | 0.5 | 1.5 |
| 6 | Activity-log diff: render Source as SourceName | — | 1 | 0.5 | 1.5 |
| 7 | OptionChangeConsumer: propagate rename to SourceName | — | 1.5 | 0.5 | 2 |
| 8 | CRM-origin Source referential-integrity audit (read-only) | — | 1 | — | 1 |
| 9 | Field-property reconciliation audit tooling | — | 1 | 0.5 | 1.5 |
| 10 | Contact backfill tooling (build, dry-run capable) | — | 3 | 1.5 | 4.5 |
| 11 | Run reconciliation + backfill in production (staged) | — | 1 | 1 | 2 |
| 12 | [FE] Contact list — display source_name | 1 | — | 0.25 | 1.25 |
| 13 | [FE] Contact detail panel — display source_name | 1 | — | 0.25 | 1.25 |
| 14 | [FE] Source filter chips — send .id | 1 | — | 0.25 | 1.25 |
| Subtotal (contact-service + FE, this squad) | 3 | 15 | 8.75 | 26.75 | |
| 15 | Webhook builder — remove custom_fields override (blocked, Phase 3) | — | 0.5 | 0.5 | 1 |
| 16 | qontak.com CRM mapper update (cross-team, blocked, external estimate) | — | ~2 | ~1 | ~3 |
| 17 | Chat producer coordination ask (cross-team, blocked, dev effort TBD) | — | TBD | TBD | ~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 — onlyContactHandler.Updatewas 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-
7dfa165sourcefield 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
| Action | File | What changes |
|---|---|---|
| run (no code) | — | Mongo query against field_properties collection |
| call | internal/app/handler/sync_field_properties_handler.go:508-546 (MigrateDefaultFieldPropertiesS2S) | invoke per batch of company_sso_ids |
Implementation steps
- Run the detection query directly against the
field_propertiescollection (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}). - 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 checkis_default/field_typebefore flipping — a wrong input list silently flips the wrong bucket. - Chunk the resulting
company_sso_idlist 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). - Call
POST /field_properties/migrate-default-fieldsper batch. - Re-run the detection query afterward; the remaining hit count should be ~0 (idempotent
no-op for already-
is_default:truecompanies).
Acceptance criteria
- Detection query returns the full affected population with no date/creation-time filter.
- Every returned
company_sso_idhas been passed through the migration endpoint. - Re-running the detection query afterward returns (near) zero hits.
- No
company_sso_idwas 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
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| QA | 0.5 |
| Total | 1 |
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
sourcefield 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
| Action | File | What changes |
|---|---|---|
| extend/verify | internal/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 |
| create | internal/app/service/source_resolver.go | new SourceResolver type wrapping the extracted logic |
| extend | internal/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 |
| extend | internal/app/repository/field_properties/resolve.go:91-103 | reuse matchDropdownOption (confirmed exact) from the new resolver — do not duplicate matching logic |
| create | internal/app/service/source_resolver_test.go | table-driven tests for resolve/create/self-heal/metric paths |
Implementation steps
- Open
internal/app/service/merge_data.goand readresolveSource(line 1108) plus its caller atResolveDatalines 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. - Write failing tests in
internal/app/service/source_resolver_test.gocovering: successful resolve, auto-create-on-unmatched, resolution failure now emits a metric, and field-property- not-found triggers a bootstrap instead of erroring. - Create
internal/app/service/source_resolver.gowith aSourceResolvertype; moveresolveSource's body into it verbatim first (no behavior change), then updateMergeDataServiceto call the new type. - 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.goimports for the pattern already used by other counters in this package). - In
internal/app/repository/field_properties/resolve.go:34-36, replace the not-found error return with a call to the existingGenerateDefaultFields-equivalent bootstrap (same one/init/calls), so a first-time-seen company self-heals instead of failing every write. - Run
go test ./internal/app/service/... ./internal/app/repository/field_properties/...until green. - Run
make mocksif the extraction changes any interface Family A/B code depends on via mockery-generated mocks, then re-run tests. make lint(or repo's configured linter) and fix findings.
Acceptance criteria
-
SourceResolverproduces identical output to the old inlineresolveSourcefor existing chat/CRM traffic (regression-tested). - A resolution failure emits a metric (not just a log line).
- A missing
sourcefield property triggers self-healing bootstrap instead of returning an error to the caller. - Existing
MergeDataServicetests 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
| Discipline | Days |
|---|---|
| Backend | 2 |
| QA | 1 |
| Total | 3 |
Assumptions: this is primarily an extraction (low risk of behavior change) plus two additive guards; no new external dependency. Reuses
matchDropdownOptionrather 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
| Action | File | What changes |
|---|---|---|
| extend | internal/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 |
| extend | internal/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/extend | ContactApiHandler, 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 |
| extend | internal/app/payload/create_contact_request_test.go, internal/app/handler/contact_handler_test.go | tests asserting resolved ID+name instead of raw passthrough |
Implementation steps
- Open
internal/app/payload/create_contact_request.goand readTransformToContactObject(func starts line 255) to see howSource: e.Source(line 326) sits among the other field mappings — note the imports already present so the resolver call fits the same style. - Write failing tests in
create_contact_request_test.goasserting that a raw string source input resolves to{id, name}via a mockedSourceResolver. - Replace the passthrough at line 326 with a call to
SourceResolver.Resolve(ctx, companySsoID, rawSource), setting bothSource(id) andSourceName(label) on the transformed object. - Repeat the same pattern in
internal/app/handler/contact_handler.go:221(Update) and in the as-yet-unverifiedContactApiHandler/ContactOpenAPIHandlerCreate/Update/SystemUpdate paths — grep the handler directory for files matchingcontact_api_handler*.go/contact_openapi_handler*.goto locate them. - Run
go test ./internal/app/payload/... ./internal/app/handler/...until green. make lint.
Acceptance criteria
- A contact created via Qontak UI/S2S/OpenAPI with a raw source string now has
Contact.Source= resolved dropdown ID andContact.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/ContactOpenAPIHandlerlocations 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
| Discipline | Days |
|---|---|
| Backend | 2 |
| QA | 1 |
| Total | 3 |
Assumptions:
ContactApiHandler/ContactOpenAPIHandlerfollow the same structural pattern asContactHandler— 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.Sourceinstead of the redundant, never-resolvedcustom_fieldscopy — 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
| Action | File | What changes |
|---|---|---|
| extend | internal/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 |
| extend | same file, lines 341, 661, 724 (all three usage sites confirmed exact) | update each call site to prefer Contact.Source/Contact.SourceName |
| extend | internal/app/repository/contact/create_serializer_test.go | tests asserting response source key matches Contact.Source, not the custom_fields copy |
Implementation steps
- Open
internal/app/repository/contact/create_serializer.goand readgetSourceFromCustomFields()(lines 210-225) plus its three call sites (341, 661, 724) to see the exact precedence logic being removed. - Write failing tests asserting: a contact with
Contact.Source="abc123"and a stale/absentcustom_fields["source"]still serializessource: "abc123"in the response. - At each of the three call sites, remove the
custom_fields-first branch; fall back tocustom_fieldsonly ifContact.Sourceis empty (State F handling, promoted properly by Task 10's backfill andget_contact.go's existing fallback). - Run
go test ./internal/app/repository/contact/...until green.
Acceptance criteria
- API response
sourcefield matchesContact.Source, not thecustom_fieldsduplicate, 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
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| QA | 0.5 |
| Total | 1 |
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_fieldsduplicate (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
| Action | File | What changes |
|---|---|---|
| extend | internal/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) |
| extend | internal/app/payload/search_contact_request_test.go | tests asserting the new filter shape |
Implementation steps
- Open
internal/app/payload/search_contact_request.goand read the current filter-building logic at lines 264-277. - Write failing tests asserting a search-by-source request builds a
bson.Mkeyed on the top-levelsourcefield (matching an ID), notcustom_fields.key/custom_fields.value. - 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). - Run
go test ./internal/app/payload/...until green.
Acceptance criteria
- Search-by-source matches contacts via
Contact.Source(ID), independent of whether acustom_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
| Discipline | Days |
|---|---|
| Backend | 1 |
| QA | 0.5 |
| Total | 1.5 |
Assumptions: FE sends
.idin 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
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
| Action | File | What changes |
|---|---|---|
| extend | internal/app/service/merge_data.go — GenerateChanges, skipFieldNames-adjacent logic at lines 816-845 (RFC cited 790-820, moved ~25 lines) | special-case Source to diff/display SourceName values |
| extend | internal/app/service/update_contact.go — GenerateChanges, 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 |
| extend | corresponding _test.go files in both packages | assert diff renders label, not ID |
Implementation steps
- Open
internal/app/service/merge_data.goand readGenerateChanges(func at line 813) to see howskipFieldNamescurrently excludes fields from the diff, and how included fields are rendered. - Write failing tests asserting a
Sourcechange renders usingSourceNamevalues, not rawSourceIDs. - Add a special case (not a skip) for
Source: when diffing, substitute the before/afterSourceNamefor display purposes while still keying the change onSource. - Repeat in
internal/app/service/update_contact.go:309-336. - Run
go test ./internal/app/service/...until green.
Acceptance criteria
- Activity-log entries for a
Sourcechange 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
| Discipline | Days |
|---|---|
| Backend | 1 |
| QA | 0.5 |
| Total | 1.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
SourceNamesilently stale — the RFC decided to persistSourceNamegoing 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
| Action | File | What changes |
|---|---|---|
| extend | internal/app/consumer/option_change.go — handleRenamedOption/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 |
| verify | internal/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 |
| extend | internal/app/consumer/option_change_test.go | tests asserting Contact.SourceName is rewritten on rename, alongside the existing custom_fields assertion |
Implementation steps
- Open
internal/app/consumer/option_change.goand readhandleRenamedOption/handleDropdownRename(lines 104-127) to see exactly how the existingcustom_fieldsrewrite is batched (per-company, per-option). - Write failing tests asserting that after a rename event, contacts with
Contact.Source == <renamed option ID>getContact.SourceNameupdated to the new label. - Add a bulk
UpdateMany/bulk-write alongside the existingcustom_fieldsrewrite, scoped to{company_sso_id, source: <optionID>}, settingsource_name: <newLabel>. - Confirm
get_contact.go:87-98's existingSourceNamepopulation logic doesn't double-write or conflict with this consumer's async update (read path should just read the persisted value, not re-resolve). - Run
go test ./internal/app/consumer/...until green.
Acceptance criteria
- Renaming a dropdown option updates every affected contact's persisted
Contact.SourceName, not just itscustom_fieldscopy. - This runs as part of the same consumer batch as the existing
custom_fieldsrewrite (no new consumer/queue needed). - No regression to the existing
custom_fieldsrename 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
| Discipline | Days |
|---|---|
| Backend | 1.5 |
| QA | 0.5 |
| Total | 2 |
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.Sourcevalues (an opaque ID CRM minted independently) don't actually match an entry in the company'sfield_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
| Action | File | What changes |
|---|---|---|
| create | internal/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
- Open
internal/server/rest_router.go:296-394to see the existing/api/v1route group pattern (BasicAuth + ContextLogger + tracing middleware; e.g.sync_field_properties_handler.go'smigrate-default-fieldsroute as a reference handler). - Write a read-only handler/script that, per company, loads
field_properties["source"].dropdownand cross-checks every CRM-origin contact'sContact.Sourceagainst the dropdown's option IDs, emitting a mismatch report (company, contact ID, stored value). - Add it as a new sub-route under
/api/v1/contacts/audit/source_referential_integrityor equivalent, following the existing/api/v1BasicAuth-protected handler pattern. - Test against a seeded set of matching/mismatching contacts.
Acceptance criteria
- Report lists every CRM-origin contact whose
Contact.Sourcehas 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
| Discipline | Days |
|---|---|
| Backend | 1 |
| Total | 1 |
Assumptions: follows the existing BasicAuth-protected
/api/v1admin-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_selectbucket Task 1 already fixes for free, this finds the two rarer buckets — companies where thesourcefield property is entirely missing, or where an admin's own unrelated custom field happens to be namedsource— 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
| Action | File | What changes |
|---|---|---|
| create | admin script or BasicAuth-protected /api/v1-style endpoint | runs the field-property state query per company, outputs the four buckets |
Implementation steps
- 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}). - Classify each company into: canonical / adoptable (Task 1's target) / missing / incompatible, per the RFC's decision tree.
- For "missing", call the self-healing bootstrap from Task 2 (or the equivalent
GenerateDefaultFieldscall directly) to create the property fresh. - For "incompatible", output a manual-review list (rename + product/CS sign-off, not automated).
- Test the classification logic against seeded fixture documents covering all four states.
Acceptance criteria
- Every company in the live
field_propertiescollection 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
| Discipline | Days |
|---|---|
| Backend | 1 |
| QA | 0.5 |
| Total | 1.5 |
Assumptions: reuses Task 2's self-healing bootstrap rather than reimplementing
GenerateDefaultFieldslogic.
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
Sourceinto 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
| Action | File | What changes |
|---|---|---|
| create | internal/app/handler/ (new handler, /api/v1 BasicAuth pattern per rest_router.go:296-394) | new backfill admin endpoint, dry-run + live modes |
| reuse | internal/app/repository/field_properties/resolve.go:91-103 (matchDropdownOption) | reuse matching rules rather than reimplementing |
| reuse | Task 2's SourceResolver/self-heal logic where applicable | consistent resolution semantics with live traffic |
Implementation steps
- Open
internal/server/rest_router.go:296-394and the reference handlers it names (e.g.sync_field_properties_handler.go'smigrate-default-fieldsroute) to match this repo's existing/api/v1BasicAuth admin-migration-endpoint conventions (request shape, batching, reporting). - Write failing tests for: batch-by-company map building, case-insensitive/trimmed matching
(reusing
matchDropdownOption), State F promotion (custom_fields["source"]→Sourcebefore resolving), State D audit-only handling (flag, don't rewrite), and theMaxDropdownItems(150,internal/app/repository/field_properties/base.go:335) pre-flight halt-and-flag behavior. - Implement: per company, fetch
field_properties["source"].dropdownonce, build an in-memory map, then bulk-$setcontacts in batches (e.g. 500) viaUpdateMany/bulk-write — no per-contact serial resolver calls. - Add dry-run mode (report counts/samples, no writes) and idempotency (skip contacts whose
Sourcealready matches an existing option ID). - Add the pre-flight
MaxDropdownItemscap check — halt and flag the company for manual review rather than let per-contact resolution start failing mid-run. - Add the aggregate-report shape matching
migrate_default_fields.go's existing per-company success/failure/total-updated pattern. - 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 (
Sourceempty,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.goaggregate 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
| Discipline | Days |
|---|---|
| Backend | 3 |
| QA | 1.5 |
| Total | 4.5 |
Assumptions: reuses
matchDropdownOptionand theSourceResolver'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
| Action | File | What changes |
|---|---|---|
| run | Task 9's tooling | field-property reconciliation, per company |
| run | Task 10's tooling | contact backfill, staged batches |
| ops | — | mongodump or subset snapshot before first production write |
Implementation steps
- Confirm Tasks 3, 4, 5 are deployed and stable (read paths + Family A resolution live).
- 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. - Run Task 9's field-property reconciliation to completion first — the backfill resolver is only as correct as the field property it resolves against.
- Pilot the contact backfill (Task 10, live mode) on low-risk/internal companies first; verify end-to-end (backend response + FE display) manually.
- Expand to the full population in batches, monitoring the aggregate report each batch.
- 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
| Discipline | Days |
|---|---|
| Backend | 1 |
| QA | 1 |
| Total | 2 |
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
| Action | File | What changes |
|---|---|---|
| extend | features/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 |
| extend | features/customers/views/components/ListTable.vue — formatCustomerData (confirmed, function at line 343, RFC cited 337-348) | display formatting uses source_name |
| extend | features/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
- Open
features/customers/views/ListPage.vueand read the column definition around line 213 ({ name: 'Source', id: 'source', ... }) to see how other columns are structured. - Write failing tests in
ListTable.spec.tsassertingformatCustomerDatarenderscustomer.source_name, notcustomer.source. - Update the column definition/formatter to read
source_name. - Run
pnpm test -- features/customers/views/components/ListTable.spec.tsuntil green. 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
| Discipline | Days |
|---|---|
| Frontend | 1 |
| QA | 0.25 |
| Total | 1.25 |
Assumptions:
source_nameis 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_defaultfield 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
| Action | File | What changes |
|---|---|---|
| extend | features/customers/detail/components/CustomerDetails.vue — getPropertyValue() (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 |
| extend | features/customers/detail/components/CustomerDetails.spec.ts (colocated convention) | assert display vs. edit-mode value split |
Implementation steps
- Open
features/customers/detail/components/CustomerDetails.vueand readgetPropertyValue()(lines 616-641) to see the existingis_defaultfallback branch this special-cases against. - Write failing tests asserting: display mode shows
source_name; edit-modev-modelstill binds tosource(ID) so the dropdown selection logic is unaffected. - Add the
source-specific branch ingetPropertyValue(). - Run
pnpm test -- features/customers/detail/components/CustomerDetails.spec.tsuntil green. 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
| Discipline | Days |
|---|---|
| Frontend | 1 |
| QA | 0.25 |
| Total | 1.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
| Action | File | What changes |
|---|---|---|
| extend | features/customers/views/ListPage.vue — getSelectedSourcesCode() (confirmed exact, lines 443-446; RFC cited ~429-433, corrected during recon) | switch .map((source) => source.name) to .map((source) => source.id) |
| verify | same 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 |
| extend | features/customers/views/ListPage.spec.ts | assert the query param sent is .id, not .name |
Implementation steps
- Open
features/customers/views/ListPage.vueand readgetSelectedSourcesCode()at lines 443-446 — confirm it currently maps.namefor both the "all selected" and specific-selection branches, sent as the'source[]'param (line 472). - Write failing tests asserting the function returns
.idvalues, not.name, for both branches. - Change both
.map((source) => source.name)calls (lines 445-446) to.map((source) => source.id). - Run
pnpm test -- features/customers/views/ListPage.spec.tsuntil green. pnpm lint && pnpm build.
Acceptance criteria
- Selecting a source filter chip sends the option's
.idas thesource[]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
| Discipline | Days |
|---|---|
| Frontend | 1 |
| QA | 0.25 |
| Total | 1.25 |
Assumptions:
fetchSourceOptions()already computes.idalongside.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 garbageCrm::Sourcerows 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
| Action | File | What changes |
|---|---|---|
| extend | internal/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
- 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.
- Open
internal/app/service/webhook_delivery_interface.go:855-867and read the exact override being removed. - Write a test asserting the webhook payload's
sourcekey now matchesContact.Sourcedirectly, with nocustom_fieldsoverride. - Remove the override block.
- Run
go test ./internal/app/service/....
Acceptance criteria
- Outbound webhook
sourcefield matchesContact.Source(ID), not thecustom_fieldslabel. - 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
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| QA | 0.5 |
| Total | 1 |
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::Sourcerows by a raw label pulled fromcustom_fields/top-levelsource, and instead readssource_namefor the display value while populatingCrm::Source.cdp_option_idfrom the now-reliablesource(ID) — closing the loop with the CRM→CDP direction, which already sendscdp_option_idtoday.
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):
| Action | File | What changes |
|---|---|---|
| extend | app/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 |
| extend | app/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 |
| verify | app/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 |
| context | app/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_sourceandsource_status_attrsboth consumesource_namefor theCrm::Sourcedisplay value, once contact-service's webhook reliably sends it. -
Crm::Source.cdp_option_idis populated from contact-service'ssource(ID) field. - No new garbage
Crm::Sourcerows created from raw UUID strings during the transition.
Effort estimate (external — rough estimate only, not this squad's velocity)
| Discipline | Days |
|---|---|
| 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
- 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).
- File the ticket with the producer team (owner: Ghozi), referencing this RFC and the specific payload shape to remove.
- 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
| Discipline | Days |
|---|---|
| Coordination (ticket-filing only) | ~0.5 |
| Producer-team dev effort | TBD — 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.