Task Breakdown: Qontak One Team Migration — Phase 1
Companion task breakdown for
rfc-qontak-one-team-migration.md, produced by therfc-task-breakdownskill (vertical slicing, full picture). Paths verified against the liveqontak-launchpad(BE,main) andqontak-launchpad-fe(FE,master) worktrees on 2026-07-01.
Effort Summary
| Story / Task | FE days | BE days | QA days | Total |
|---|---|---|---|---|
| Task 1 — [BE] OTM-S04 source-tracking columns | — | 2 | 0.5 | 2.5 |
Task 2 — [BE] OTM-S05 pushed-migration receive + TEAM_MIGRATED ⚠️ | — | 3 | 1 | 4 |
| Task 3 — [BE] OTM-S02 General-team source cols + retry | — | 2 | 0.5 | 2.5 |
| Task 4 — [FE+BE] OTM-S01 mandatory team on invite | 1.5 | 2 | 1 | 4.5 |
| Task 5 — [BE] OTM-S05 Chat/CRM trigger+event contract ✅ | — | 1.5 | 0.5 | 2 |
| Actionable total | 1.5 | 10.5 | 3.5 | 15.5 |
| Task 6 — [Cross-squad] OTM-S03 team URL change 🚫 | n/a (other repo) | — | — | — |
Confidence: medium. The receive-side (columns,
createSingleTeamextension,TEAM_MIGRATED) and invite work are well-grounded in verified code. Task 5 unblocked 2026-07-01 — both Chat and CRM signed off the trigger endpoint +TEAM_MIGRATEDevent contract (OQ-1/OQ-9 closed, REV-1 resolved at RFC R3), so it now folds into the actionable total (BE 1.5 + QA 0.5). Remaining open items are implementation-time decisions, not gates: the General-team retry backoff params are unverified (OQ-7) and the FE gating signal defaults to Option A (OQ-4). Only Task 6 (out-of-repo legacy-CRM URL, OQ-3) is excluded from the grand total.
Task 1: [BE] Source-tracking columns on teams (OTM-S04)
A Launchpad team records whether it came from Chat, CRM, or was created natively — so any team can be traced to its source.
Status: ✅ Actionable Design reference: n/a — backend schema, no UI
What to build
Add source_identifier + reference_id to the teams table (golang-migrate), extend the sqlc CreateTeam query + regenerate the model, and update the one caller that builds CreateTeamParams.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | db/migration/<ts>_add_source_tracking_to_teams.up.sql | ADD COLUMN source_identifier TEXT NOT NULL DEFAULT 'Launchpad', reference_id TEXT; CHECK (source_identifier IN ('Chat','CRM','Launchpad')); partial unique index (company_id, source_identifier, reference_id) WHERE reference_id IS NOT NULL |
| create | db/migration/<ts>_add_source_tracking_to_teams.down.sql | drop index, constraint, both columns |
| extend | db/query/team.sql | CreateTeam insert to accept source_identifier, reference_id |
| regen | internal/app/repository/team.sql.go | sqlc generate → CreateTeamParams gains cols |
| regen | internal/app/repository/models.go | Team struct gains SourceIdentifier, ReferenceID |
| extend | internal/app/service/teams/create.go (~L102) | createSingleTeam passes new cols into CreateTeamParams (default 'Launchpad'/NULL) |
Implementation steps
- Explore — open
db/migration/20250410080041_create_teams.up.sqlanddb/query/team.sql(-- name: CreateTeam :one) to match dialect + annotation style. - Write the migration — create the up/down pair; run
make migrate-upthenmake migrate-downthenmake migrate-up. - Extend the query — add the two columns to
CreateTeamindb/query/team.sql. - Regenerate — run sqlc; confirm
CreateTeamParams/Teamininternal/app/repository/team.sql.go+models.gocarry the cols. - Fix callers —
createSingleTeam(create.go) sets the new params (default Launchpad/NULL for now — real values land in Tasks 2/3). - Green —
make test. - Quality gate —
make lint.
Acceptance criteria
-
\d teamsshowssource_identifier,reference_id,chk_teams_source_identifier,idx_teams_source_ref_unique - Inserting a team with
source_identifier='Foo'is rejected by the CHECK -
make migrate-downreverts cleanly;make testpasses
Test strategy
Existing create_test.go compiles against the new CreateTeamParams; add a repository/migration assertion that a bad source_identifier is rejected.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 2 |
| QA | 0.5 |
| Total | 2.5 |
Assumptions: sqlc + golang-migrate already wired; only one caller of
CreateTeamParamstoday.
Run to verify
make migrate-up && make test && make lint
Depends on
- None (foundation)
Task 2: [BE] Pushed-migration receive path + TEAM_MIGRATED event (OTM-S05)
Chat/CRM push their mapped teams into Launchpad's existing bulk endpoint; Launchpad stores them with source tracking, is replay-safe, and emits an event each squad uses to map its division/team back to the centralized team.
Status: ⚠️ Partially blocked — the receive side (bulk-create extension + TEAM_MIGRATED + idempotency) is fully actionable now; the outbound trigger client to Chat/CRM is stubbed pending the contract in Task 5 (OQ-1).
Design reference: n/a — backend + Kafka, no UI
What to build
Extend the existing POST /iag/v1/teams/bulk path: add app_identifier_id (required when is_migrate=true), relax member_ids for migrate-mode, map app→source_identifier + persist app_identifier_id→reference_id, skip-on-conflict for idempotency, and emit a new TEAM_MIGRATED Kafka event instead of TEAM_CREATED for migrate-mode. Scaffold (stub) the Chat/CRM trigger client.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | internal/pkg/request/team_request.go (L9, Validate L20/L34/L55) | add AppIdentifierID string json:"app_identifier_id"; require it when IsMigrate (mirror the IsMigrate && App=="" guard L34); allow empty MemberIDs when IsMigrate |
| extend | internal/app/service/teams/create.go (createSingleTeam, prefix L84) | map app→source_identifier, persist app_identifier_id→reference_id; check (company,source,reference_id) before insert (skip on conflict); call the migrated-event publisher for is_migrate |
| extend | internal/app/service/teams/events.go (L32, L130) | add TeamMigratedEvent/payload (+app, +app_identifier_id) + maybePublishTeamMigratedEvent (EventType:"TEAM_MIGRATED", topic TopicTeamEventsV1, gated FeaturePublishTeamUpdate) |
| create | internal/app/api/chat/trigger_migration.go, internal/app/api/crm/trigger_migration.go | stub trigger method (heimdall client shape from iChatClient.go:44); real endpoint pending Task 5 |
| extend | internal/pkg/request/bulk_create_team_test.go, internal/app/service/teams/create_test.go, events_test.go, bulk_create_test.go | cover validation, mapping, idempotency, event |
Implementation steps
- Explore — read
internal/pkg/request/team_request.go(theIsMigrate/Appvalidation),create.gocreateSingleTeam(prefix logic L84 + themaybePublishTeamCreatedEventdefer), andevents.goL87–150 (the existing publisher). - Red — extend
bulk_create_team_test.go: migrate item w/omember_idsaccepted, migrate item w/oapp_identifier_idrejected; increate_test.go/events_test.goassert source cols persisted +TEAM_MIGRATEDemitted; runmake test(fail). - Request struct — add
AppIdentifierID+ the two validation rule changes. - Persist + idempotency — in
createSingleTeam, set source cols fromapp/app_identifier_id; pre-check the unique key and skip on conflict. - Event — add
TeamMigratedEvent+ publisher inevents.go; branch onis_migrateto emitTEAM_MIGRATEDinstead ofTEAM_CREATED. - Trigger stub — add
TriggerMigrationon the chat/crm clients returning a not-implemented/stub result (compiles); note "real call in Task 5 once OQ-1 resolves". - Green + gate —
make test && make lint.
Acceptance criteria
- Migrate item without
member_idsis accepted; withoutapp_identifier_idreturns 400 -
app=chat+app_identifier_id=4821⇒ teamChat - Supportwithsource_identifier='Chat',reference_id='4821' - Re-posting the same item creates 0 new rows (idempotent)
-
TEAM_MIGRATEDpublished withteam_id+app+app_identifier_id; native creates still emitTEAM_CREATED - (pending OQ-1) real trigger call — stubbed; tracked in Task 5
Test strategy
go test on request + teams packages: table tests for the new validation; a repo-backed test asserting source cols + skip-on-conflict; an events test asserting EventType=="TEAM_MIGRATED" and payload. Mock the Kafka producer (as existing events_test.go does).
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 3 |
| QA | 1 |
| Total | 4 |
Assumptions: reuses the existing async bulk path + Kafka producer; no new topic; trigger is a stub only.
Run to verify
go test ./internal/pkg/request/... ./internal/app/service/teams/... && make lint
Depends on
- Task 1 (source columns must exist)
- [External: Chat/CRM trigger + event contract — OQ-1 → Task 5] for the real trigger call
Task 3: [BE] General-team source cols + async retry (OTM-S02)
Every new CID still auto-gets a "General" team — now stamped as a native Launchpad team, and reliably created even if the first attempt fails.
Status: ✅ Actionable (core already built — CreateTeamForCompany is called at migrate.go:148)
Design reference: n/a — backend only
What to build
Stamp source_identifier='Launchpad', reference_id=NULL on the auto-created team, normalize the name "general"→"General", and add an async retry when creation fails (today it's non-fatal log-only, no retry).
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | internal/app/service/teams/create.go (CreateTeamForCompany L18) | pass source_identifier='Launchpad', reference_id=NULL; name "General" |
| extend | internal/app/service/companies/migrate.go (L148), migrate_full.go (L393) | on teamErr, enqueue a retry job (keep company creation non-rolled-back) |
| create | retry job handler ([unverified — locate the jobEnqueuer/queue package used by TeamService in teams/main.go]) | idempotent: check (company_id,'General') before insert; alert on exhaustion |
| extend | internal/app/service/companies/migrate_test.go (TestCompanyService_MigrateCompany_GeneralTeam) | assert cols set + retry enqueued on injected failure |
Implementation steps
- Explore — read
companies/main.go:28-31(teamCreatorinterface) andmigrate.go:138-153(the non-fatal call site), and locate thejobEnqueuer/queuepackage used byTeamService(teams/main.go). - Red — extend
migrate_test.go: created team has source cols + name"General"; injectedCreateTeamForCompanyfailure enqueues a retry and does not roll back the company. - Stamp — update
CreateTeamForCompanyto set the cols + capitalized name. - Retry — on
teamErr, enqueue the retry job; implement the handler (existence-checked, idempotent). - Backoff params — read the queue package defaults; pin attempts/backoff (OQ-7).
- Green + gate —
make test && make lint.
Acceptance criteria
- New CID ⇒
Generalteam withsource_identifier='Launchpad',reference_id=NULL - Injected creation failure ⇒ company not rolled back + retry enqueued
- Retry is idempotent (no duplicate General on success-after-retry)
Test strategy
Extend migrate_test.go with the existing mockTeamCreator; assert the params passed to CreateTeamForCompany and that a failure path enqueues via a mocked jobEnqueuer.
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 2 |
| QA | 0.5 |
| Total | 2.5 |
Assumptions:
CreateTeamForCompany+ call sites already exist; retry reuses the existingjobEnqueuer. Retry backoff params unverified (OQ-7).
Run to verify
go test ./internal/app/service/companies/... ./internal/app/service/teams/... && make lint
Depends on
- Task 1 (source columns)
Task 4: [FE+BE] Mandatory team on user invitation (OTM-S01)
When the migration flag is on, an Admin must pick a team when inviting a user, and that assignment is actually saved.
Status: ✅ Actionable (BE + FE both buildable; FE gating signal decision pending OQ-4 — default to Option A)
Design reference: n/a — design pending (PRD §8); reuses existing field-teams component, no new frame
What to build
BE: extend SsoInvite to accept teamIds, validate non-empty when the flag is on, write team_users, add the launchpad_one_team_migration flag constant, and reject cross-tenant teams. FE: forward teamIds from the invite store (currently dropped) and make the team field required (gated) with loading/empty/error states.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | internal/app/service/users/sso_invite.go (ApiUserInviteParam L23) | add TeamIds []string (tagless → binds teamIds); when flag on + empty ⇒ ErrBadRequest; write team_users via CreateTeamUser; validate teams belong to inviter's company |
| extend | internal/pkg/constants/preferences.go (L4) | add FeatureOneTeamMigration = "launchpad_one_team_migration" |
| extend | internal/app/service/users/sso_invite_test.go (or handler/user_handler_test.go:525) | flag on+empty ⇒ 400 resp_desc.en; flag on+teams ⇒ N team_users; flag off ⇒ unchanged; cross-tenant rejected |
| extend | app/features/user_management/composables/useUsers.ts (inviteUser L294–306) | include teamIds in paramsToSend (no casing transform) |
| extend | app/features/user_management/users/invite/views/InviteUsers.vue (yup L318, states L188–210) | teams: yup.array().min(1) gated by the migration flag; empty/error/retry states |
| extend | InviteUsers spec [unverified — check repo for spec convention] | submit blocked w/o team when enforced; payload includes teamIds |
Implementation steps
- Explore (BE) — read
sso_invite.go:23-269(ApiUserInviteParam,createLaunchpadUser) +get_team_menu_visibility.go:44(preference.IsEnabledpattern) +default_error.go(ErrBadRequest). - Red (BE) — add
sso_invitetests for the four cases;make test(fail). - BE impl — add
TeamIds, flag check,team_userswrite loop (CreateTeamUser, ON CONFLICT DO NOTHING), cross-tenant guard; add the flag const. - Explore (FE) — read
useUsers.ts:294(paramsToSenddropsteamIds) +InviteUsers.vue:318,351(yup +onSubmit). - Red (FE) — spec: submit blocked without team when enforced; posted body includes
teamIds. - FE impl — add
teamIdstoparamsToSend; make yup rule required gated on the migration flag (OQ-4 Option A: derive fromGET /teams/menu-visibility); wire empty/error/retry states. - Green + gate — BE
make test && make lint; FEpnpm run test -- --run && pnpm run type-check && pnpm run lint.
Acceptance criteria
- Flag on + no team ⇒ 400 with
resp_desc.en == "Please select a team to continue" - Flag on + team(s) ⇒ user created +
team_usersrow(s) written - Flag off ⇒ existing behavior unchanged; existing users unaffected (NEG-1)
- Selecting a team from another company is rejected
- FE posts
teamIds; submit disabled until a team is selected when enforced
Test strategy
BE: go test on the users service with the four flag/team permutations, asserting team_users count + the 400 body. FE: vitest + @testing-library/vue — mock the invite store, assert submit-guard and that inviteUser is called with teamIds.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 1.5 |
| Backend | 2 |
| QA | 1 |
| Total | 4.5 |
Assumptions: reuses existing
field-teams,useTeamsstore,CreateTeamUser. FE gating resolves to Option A (menu-visibility encodes the flag).
Run to verify
# BE
go test ./internal/app/service/users/... && make lint
# FE
pnpm run test -- --run && pnpm run type-check && pnpm run lint
Depends on
- None hard (independent of migration columns) — but ship after Task 1 if sequencing one PR train
- Decision: OQ-4 (FE gating) — recommend Option A before FE half
Task 5: [BE] Chat/CRM migration-trigger + TEAM_MIGRATED contract ✅ (OTM-S05, cross-squad)
Launchpad can kick off each squad's migration, and each squad consumes
TEAM_MIGRATEDto map its division/team to the centralized team.
Status: ✅ Actionable (unblocked 2026-07-01) — the cross-squad contract (OQ-1 / OQ-9) is signed off by both Chat and CRM: the trigger endpoint (path, auth, payload) and the TEAM_MIGRATED topic/payload are agreed. Build it out to complete end-to-end migration.
What to build
Replace the stub trigger client (Task 2) with real calls to the Chat/CRM trigger endpoints; confirm the TEAM_MIGRATED topic/payload with consumers.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | internal/app/api/chat/trigger_migration.go, internal/app/api/crm/trigger_migration.go | real heimdall call to the squads' trigger endpoint |
| extend | config (base URL + service creds, existing config pattern) | trigger endpoint config |
Acceptance criteria
- Trigger call succeeds against each squad's endpoint (timeout/retry via heimdall)
- Consumers confirm they receive + map
TEAM_MIGRATED
Effort estimate
| Discipline | Days |
|---|---|
| Backend | 1.5 |
| QA | 0.5 |
| Total | 2 |
Depends on
- [External: OQ-1 trigger contract + OQ-9 event sign-off — Chat + CRM squads] ✅ signed off 2026-07-01
- Task 2 (stub client to replace)
Task 6: [Cross-squad] Team sidebar URL change /qontakcrm/team → /launchpad/team 🚫 (OTM-S03)
Team management is reachable under the unified Launchpad URL, with the old URL redirecting.
Status: 🚫 Blocked / out of repo — neither /qontakcrm/team nor /launchpad/team exists in qontak-launchpad-fe (real route is /user_management/teams). This lives in the legacy CRM app. Unblocks when the owning squad/repo is confirmed (OQ-3).
What to build (when owned)
Route + redirect in the legacy CRM app; not a qontak-launchpad/-fe change.
Effort estimate
| Discipline | Days |
|---|---|
| — | n/a in launchpad repos |
Depends on
- [External: OQ-3 — confirm owning squad/repo + PRD scope reconciliation]
Ordering rationale
- Task 1 is the foundation — the columns must exist before the migration receive path (Task 2) and the General-team stamping (Task 3) can persist them. Do it first.
- Task 2 is the critical path for migration and is mostly reuse of the existing bulk endpoint — build the receive side first with the trigger stubbed, then complete the real trigger in Task 5 (now unblocked).
- Task 4 (invite) is independent of the migration columns and can run in parallel with Tasks 2/3 (different files) — good candidate for the FE dev to start while BE does the migration work. Its only gate is the OQ-4 gating decision (small).
- Task 5 (trigger) is now unblocked (OQ-1/OQ-9 signed off by Chat + CRM, 2026-07-01) — sequence it after Task 2's receive side to complete end-to-end migration.
- Task 6 (OTM-S03) isn't launchpad work — route it to the legacy-CRM owner rather than holding this initiative.
Skipped stories
| Story | Reason |
|---|---|
| OTM-S03 | Out of repo — team URL change belongs to the legacy CRM app, owner unconfirmed OQ-3 (detailed as Task 6) |