RFC: Qontak One Team Migration — Phase 1: Legacy Team Migration
Document Conventions (do not remove)
This RFC follows the Qontak RFC Template format for governance — the metadata table, Confluence sections 1–6, and Comment logs are mandatory. Mark sections
N/A — reasonwhen truly inapplicable rather than deleting them.It is also agent-execution-ready: §1 Design References (FE) + §1 PRD-to-Schema Derivation (BE), §2 Repo Reading Guide (Detail 2.0), mermaid diagrams, §2.G Cross-Layer Contract Verification, and §4 Agent Execution Plan
- Verification & Rollback Recipe must be complete before §7 Ready for agent execution: yes.
Delivery & project management live elsewhere. This RFC is the technical artifact only — no staffing, effort, timeline, or rollout schedule. Those live in the initiative's
delivery/folder. Until this RFC is handed to delivery, the Delivery row readsnot yet handed to delivery.Grounding note (anti-hallucination). Every file path, struct, function, route, migration filename, and command below was read in the live worktrees at
/Users/mekari/Documents/repos/qontak-launchpad(Go BE, branchmain) and/Users/mekari/Documents/repos/qontak-launchpad-fe(Nuxt 4 FE, branchmaster) on 2026-06-30. Detail 2.0's Source Verification table carries the per-anchor evidence. Where the PRD's described behaviour already exists or does not match the code, this RFC says so explicitly rather than restating the PRD.
Metadata
| Field | Value | Notes |
|---|---|---|
| Status | RFC (DRAFT) | Human label; YAML status: carries linter enum draft |
| DRI | Grehasta | Accountable RFC owner (Backend). Per-task staffing lives in delivery/ once handed off. |
| Team | bifrost | Advisory squad slug carried from PRD / initiative README |
| Author(s) | Grehasta (BE), Syafrizal M. (FE) | Initiative implementors (initiative README) |
| Reviewers | Bifrost Tech Lead, Chat Squad Tech Lead, CRM Squad Tech Lead | Cross-squad: Chat & CRM own the source read APIs |
| Approver(s) | Bifrost Tech Lead, Infosec approver | Migration touches access/membership data |
| Submitted Date | 2026-06-30 | ISO-8601 |
| Last Updated | 2026-06-30 | ISO-8601; bump on every material edit |
| Target Release | 2026-Q3 | Quarter |
| Target Quarter | 2026-Q3 | Advisory; carried from PRD |
| Delivery | not yet handed to delivery | Update once delivery/timeline.md references this RFC |
| Related | ../prds/prd-qontak-one-team-migration.md, ../prds/prd-qontak-one-team-anchor.md | Source PRD + ANCHOR |
| Discussion | TBD — pending Bifrost engineering channel | Alerts route to #bifrost-alerts (PRD §12) |
Type: full-stack
Frontend sub-type: enhancement (extends the existing InviteUsers.vue team field + invite store)
Backend sub-type: new-feature (new columns, extended bulk-create migration path + trigger, invite team-assignment write)
Sections at a Glance
- Overview (problem, success criteria, Design References [FE], PRD-to-Schema Derivation [BE], traceability, decisions, per-story change map)
- Technical Design (Repo Reading Guide → end-to-end mermaid → DDL → APIs → cross-layer contract verification → data flow)
- High-Availability & Security
- Backwards Compatibility and Rollout Plan (cross-layer rollout matrix, Agent Execution Plan, Verification & Rollback Recipe)
- Concern, Questions, or Known Limitations
- Comment logs
- Ready for agent execution
1. Overview
Qontak One is centralizing User Management into Launchpad (the usman module). Today, team structures are fragmented: Chat keeps "Divisions" and CRM keeps "Teams", each in its own system, with no unified record in Launchpad and no way to trace where a Launchpad team came from. This RFC implements Phase 1 — Legacy Team Migration:
- Add source tracking (
source_identifier,reference_id) to the Launchpadteamstable so every team records whether it was migrated from Chat, migrated from CRM, or created natively in Launchpad. - Provide an idempotent migration receive path (extending the existing
POST /teams/bulk) plus a trigger to Chat/CRM: each squad maps its own active Divisions/Teams and pushes them in (carrying an optionalapp_identifier_id= the source division/team id), and Launchpad creates source-prefixed teams (Chat - <name>,CRM - <name>). On each migrated create, Launchpad emits a separateTEAM_MIGRATEDKafka event carryingapp+app_identifier_id+ the new Launchpadteam_id, so each source squad can map its own division/team back to the centralized Launchpad team.
Naming convention (request/event ↔ storage). On the wire (bulk-create request + the
TEAM_MIGRATEDevent) the source-app vocabulary is used:app(chat/crm) andapp_identifier_id(the app's own division/team id, optional string). These persist into Launchpad's storage vocabulary:app→teams.source_identifier(Chat/CRM/Launchpad),app_identifier_id→teams.reference_id.app→source_identifieris already the established mapping in the code (create.go:84);app_identifier_id→reference_idis the net-new half.
- Enforce team assignment on new user invitations when the migration is enabled for a CID.
- Stamp the already-existing auto-created "General" team on CID creation with
source_identifier = 'Launchpad',reference_id = NULL.
All of the above is gated behind a new one_team_migration_enabled flag (global, default off) layered on top of the existing unified_app gate.
Material grounding deltas the reviewer must know up front (full evidence in §2.0):
- OTM-S02 ("General" team auto-create on CID creation) is already implemented.
companies/migrate.go:148andmigrate_full.go:393already callteamCreator.CreateTeamForCompany(ctx, newCompany.ID, companySsoID, "general"). The remaining work is (a) stamping the new source columns, (b) reconciling the name casing"general"vs the PRD's"General", and (c) the PRD's "retried asynchronously" — not implemented today (current behaviour is non-fatal log-and-continue, no retry).- The FE invite team field already exists (
InviteUsers.vuefield-teams, gated byteamsStore.isTeamMenuVisible), but it is optional and the invite store dropsteamIdsbefore the HTTP POST (useUsers.ts inviteUseronly forwardsemail, fullName, inviterSsoID, nik, phone, roleId, staffLevel). So teams never reach the backend today.- OTM-S03 (team sidebar URL
/qontakcrm/team→/launchpad/team) does not live in either launchpad repo. The real Launchpad route is/user_management/teams; neither/qontakcrm/teamnor/launchpad/teamappears anywhere inqontak-launchpad-fe. This story is out of scope for the Launchpad repos and is tracked as a cross-squad item — see §5 OQ-3.- Migration is a push model, and Launchpad's existing bulk endpoint already supports it. Per service planning (PRD v1.2), each source squad maps its own data and calls Launchpad's create endpoints — Launchpad does not read Chat/CRM data. The existing
POST /iag/v1/teams/bulk(request.CreateTeam) already carriesIsMigrate bool(json:"is_migrate") andApp string(json:"app", validatedchat/crm), runs async viaEnqueueBulkCreateTeam/ProcessBulkCreateTeam, prefixes the name on collision (item.App + "-" + item.Name,create.go:84), and already emits theTEAM_CREATEDKafka event (createSingleTeam→maybePublishTeamCreatedEvent,events.go:130, topicTopicTeamEventsV1, gated byFeaturePublishTeamUpdate). What it does not do yet: accept anapp_identifier_id, persistsource_identifier/reference_id(theappis used only for name-prefixing;CreateTeamParamsis still{CompanyID, Name, ParentID}), enforcereference_id-based idempotency, or emit a migration-specific event. So the net-new Launchpad work is extending the existing create path (addapp_identifier_id, persist source cols, idempotency, emitTEAM_MIGRATEDinstead ofTEAM_CREATEDfor migrate-mode), plus a trigger to Chat/CRM (contract owned by them — PRD §15).
Success Criteria
- Migration command processes all active CIDs and, for each, creates one Launchpad team per active Chat Division and CRM Team with correct prefix,
source_identifier, andreference_id. Re-running creates zero duplicates (idempotent onreference_id). - Migration failure rate ≤ 1% of teams attempted (PRD §13); a failed record is logged with CID + reason and the command continues.
- For CIDs with
one_team_migration_enabled = true, 100% of successful new invitations carry a team assignment (team_usersrow written); invitations with no team are rejected with a typed validation error. - The auto-created "General" team on new CID creation carries
source_identifier = 'Launchpad',reference_id = NULL. - Toggling
one_team_migration_enabled = falserestores the prior invite behaviour (team optional, not enforced) with no redeploy.
Out of Scope
- Migrating individual user-to-team memberships — only team structures are migrated (PRD §5.1).
- Modifying the existing Chat Divisions UI (PRD §5.2).
- Merging duplicate team names across Chat and CRM — each migrates as a separate prefixed team (PRD §5.3).
- Migrating inactive/archived Divisions/Teams (PRD §5.4).
- Retroactively enforcing team assignment on existing users (PRD §5.5).
- The Chat/CRM SUPPORT PRDs that build the Division/Team read endpoints (PRD §5.6) — consumed here as dependencies.
- Billing/quota model changes (PRD §5.7).
- OTM-S03 team sidebar URL change — not a
qontak-launchpad/-fechange (see §5 OQ-3). Carried asdeferred — cross-squad (legacy CRM app)in Detail 1.C.
Related Documents
- PRD (Phase 1):
../prds/prd-qontak-one-team-migration.md - ANCHOR PRD:
../prds/prd-qontak-one-team-anchor.md— OQ1/OQ3 decisions referenced in PRD §16 - Source Confluence PRD: https://jurnal.atlassian.net/wiki/spaces/QON/pages/51206553626
- Initiative README:
../README.md—jira_epic: BIF-8642, QA Lane B
Assumptions
- Migration is a push model (PRD v1.2): each source squad maps its own active Divisions/Teams and calls Launchpad's existing bulk/single create endpoint with
is_migrate=true,app=chat|crm, and a newreference_id. Launchpad does not read Chat/CRM data. The trigger endpoint each squad exposes (so Launchpad can kick off their migration) is not yet defined — see §5 OQ-1. reference_idis unique persource_identifierper company (a Chatdivision_idand a CRMteam_idmay collide numerically), so idempotency keys on(company_id, source_identifier, reference_id)— confirmed by the PRD's prefix-don't-merge decision (PRD §16, OTM-S05/AC-3).- Migration is triggered by Launchpad (PRD §9 behavior #5) but executed by each squad (mapping + the create calls); Launchpad provides the trigger + the idempotent create + the Kafka events. The existing bulk path is already async (
EnqueueBulkCreateTeamreturns 202 + an upload job) and worker-processed (ProcessBulkCreateTeam). one_team_migration_enabledis a global flag (PRD §6), evaluated through the existingqontak-preferencesservice the same waylaunchpad_show_team_menuis today.- The PRD's
team_id(singular) on invite is reconciled against the FE's existing multi-selectteamIds— see §5 OQ-2; this RFC specifies the backend to acceptteamIds: []and require ≥ 1 when enforced.
Dependencies
| Dependency | Owning team | Deliverable | Availability | Blocking? |
|---|---|---|---|---|
| Chat squad migration | Chat Squad | Chat maps its own active Divisions, calls Launchpad's bulk create (is_migrate, app=chat, app_identifier_id=division_id), and consumes TEAM_MIGRATED to map division→Launchpad team | needs building (by Chat) | YES |
| CRM squad migration (mapping) | CRM Squad | CRM maps its own active Teams, calls Launchpad's create (app=crm, app_identifier_id=team_id), and consumes TEAM_MIGRATED to map team→Launchpad team | needs building (by CRM) | YES |
| Chat/CRM migration-trigger endpoint | Chat + CRM Squads | Endpoint Launchpad calls to kick off each squad's migration (contract: path, auth, payload) | needs defining | YES (shapes the trigger; see §5 OQ-1) |
| CRM-side migration completion | CRM Squad | CRM Teams migrated before Phase 2 staged rollout | needs building | YES (gates Phase 2) |
qontak-preferences flag launchpad_one_team_migration | Bifrost (provision) | New global flag | exists (service); flag value needs provisioning | YES |
@mekari/pixel3 1.0.8 | Design System | MpFormControl/MpInput/MpPopover etc. | exists (package.json:64) | no |
| Figma frames for invite enforcement + empty/error states | Design | Frame links | needs design (PRD §8 "TBD — pending design") | no (FE is enhancement of an existing form; see §1 Design References) |
Design References (frontend half — required)
| PRD-named surface | Figma / design link | Frame name | Design system version | Design QA contact | Notes |
|---|---|---|---|---|---|
/user/invite mandatory Team dropdown | n/a — design pending (PRD §8 "Figma: TBD") | — | @mekari/pixel3@1.0.8 (package.json:64) | TBD — pending design | Surface already exists as field-teams in InviteUsers.vue:196; this is a required-state + payload-wiring enhancement, not a new frame. Pixel-faithful to current form. |
/launchpad/team route render + redirect (OTM-S03) | n/a — out of repo | — | — | — | Route absent from qontak-launchpad-fe (real route /user_management/teams). Cross-squad; see §5 OQ-3. |
The invite enhancement reuses existing Pixel form components and the existing
field-teamslayout; it does not introduce a new frame. The empty/error copy strings (PRD §8 UI States) need design sign-off but do not block coding — they are tracked in §3.C Error Message Catalog. No frontend chunk is started against an imagined new frame.
PRD-to-Schema Derivation (backend half — required)
| PRD entity / attribute / rule | Persisted as (table.column) | Exposed via (endpoint / event / command) | Enforced where | Source |
|---|---|---|---|---|
| A team records which system it came from | teams.source_identifier ('Chat'/'CRM'/'Launchpad') | migration command + CreateTeam write + invite/General writes | DB CHECK constraint + write paths | PRD §8 OTM-S04 |
| A team links back to its source record | teams.reference_id (text, nullable) | migration command write | write paths; NULL for native | PRD §8 OTM-S04 |
Migrating a Chat Division yields Chat - <name> | teams.name, teams.source_identifier='Chat' (from app), teams.reference_id=<division_id> (from app_identifier_id) | Chat calls POST /teams/bulk (is_migrate, app=chat, app_identifier_id) | createSingleTeam persist + unique partial index | PRD §8/§10 OTM-S05/AC-1 |
Migrating a CRM Team yields CRM - <name> | teams.name, teams.source_identifier='CRM', teams.reference_id=<team_id> (from app_identifier_id) | CRM calls POST /teams/bulk (app=crm, app_identifier_id) | createSingleTeam persist + unique partial index | PRD §10 OTM-S05/AC-2 |
| Re-running migration creates no duplicates | unique index on (company_id, source_identifier, reference_id) | create path skips on conflict | DB unique index + createSingleTeam pre-check | PRD §10 OTM-S05/AC-3 |
| Only ACTIVE source records migrate | n/a — owned by source squad's mapping | source squad submits active only | enforced in Chat/CRM (not Launchpad) | PRD §10 OTM-S05/AC-4 |
| Launchpad triggers each squad's migration | n/a (no persistence) | outbound trigger call to Chat/CRM | new client methods (api/chat, api/crm) | PRD §9 behavior #5 |
| Source squad maps its division/team → centralized team | n/a (event carries it) | TEAM_MIGRATED Kafka event (team_id + app + app_identifier_id) | createSingleTeam emits TEAM_MIGRATED for migrate-mode | PRD §12 team_migrated; this case's requirement |
| New invite requires a team (when enabled) | team_users(user_id, team_id) row(s) | POST /iag/v1/users/sso_invite | UserService.SsoInvite validation + CreateTeamUser | PRD §10 OTM-S01/AC-1,AC-2,ERR-1 |
| Existing users without a team unaffected | n/a — no backfill | n/a | no enforcement on update paths | PRD §10 OTM-S01/NEG-1 |
| "General" team auto-created on CID creation | teams row (name='General', source_identifier='Launchpad', reference_id=NULL) | CompanyService.MigrateCompany → CreateTeamForCompany (exists) | companies/migrate.go:148, create.go:18 | PRD §10 OTM-S02/AC-1 |
| Migration toggled per the rollout flag | n/a (preference value) | qontak-preferences launchpad_one_team_migration | preference.IsEnabled checks at invite + UI visibility | PRD §6/§11 |
Every §2.3 DDL row and §2.4 endpoint traces back to a row here.
Detail 1.A — PRD Traceability (cross-layer)
Composite AC ids are story-qualified (<STORY-ID>/AC-n).
Forward (PRD AC → RFC):
| PRD composite AC id | FE section / component | BE section / endpoint |
|---|---|---|
OTM-S01/AC-1 | §2.A InviteUsers.vue team field required-state; §2.C | flag read GET /teams/menu-visibility (§2.4) |
OTM-S01/AC-2 | §2.A useUsers.ts inviteUser forwards teamIds | POST /iag/v1/users/sso_invite writes team_users (§2.4) |
OTM-S01/ERR-1 | §3.C error message "Please select a team to continue" | §3.B ErrBadRequest-shaped 400 (§2.4) |
OTM-S01/ERR-2 | §2.C error state + retry on team-list fetch | GET /teams failure (§3.A) |
OTM-S01/NEG-1 | n/a — no FE change on existing-user update | no enforcement on update paths (§2.4) |
OTM-S02/AC-1 | n/a — backend only | CreateTeamForCompany stamps source cols (§2.3, §2.4) |
OTM-S02/AC-2 | team appears in field-teams list | GET /teams returns General (§2.4) |
OTM-S02/ERR-1 | n/a | non-fatal create + new async retry (§2.F) |
OTM-S03/AC-1..3 | n/a — out of repo (§5 OQ-3) | n/a |
OTM-S04/AC-1..4 | n/a — backend schema | teams.source_identifier/reference_id + CHECK (§2.3) |
OTM-S05/AC-1..4, ERR-1 | n/a — backend | extend POST /teams/bulk create path to persist source cols + reference_id idempotency; trigger client (§2.4, §4.D) |
Reverse (RFC → PRD AC):
| New FE component / BE endpoint / dependency | PRD composite AC id it serves |
|---|---|
teams.source_identifier + reference_id columns + CHECK | OTM-S04/AC-1..4 |
| Extended bulk/single create (persist source + reference_id idempotency) + trigger client | OTM-S05/AC-1..4, OTM-S05/ERR-1 |
teamIds param on SsoInvite + team_users write | OTM-S01/AC-2 |
Required-state on field-teams + store payload fix | OTM-S01/AC-1, OTM-S01/AC-2 |
Source-column stamping on CreateTeamForCompany | OTM-S02/AC-1 |
| Async retry of General-team creation | OTM-S02/ERR-1 |
UI / Consumer Surface Coverage
| PRD-named surface | Consumer | Required reads (BE) | Required writes (BE) | FE component | Status surface |
|---|---|---|---|---|---|
/user/invite (real: /user_management/users/invite) | web (Admin) | GET /teams/menu-visibility, GET /teams | POST /users/sso_invite (now with teamIds) | InviteUsers.vue + field-teams (FieldSelectTeams.vue) | invite success toast; team_users row |
/launchpad/team (OTM-S03) | web | n/a | n/a | n/a — out of repo | n/a — see §5 OQ-3 |
| Migration (no UI) | Chat/CRM squad services → Launchpad | n/a — squads push | POST /teams/bulk (is_migrate,app,app_identifier_id) → teams inserts | TEAM_MIGRATED Kafka event + bulk upload-job result | |
| New-CID General team (no UI) | system | n/a | teams insert via CreateTeamForCompany | n/a | slog "Created company" + team event |
Role Coverage
| PRD role | Authorization mechanism | Endpoints permitted (BE) | UI surface visibility (FE) | Cross-tenant? | Audit trail |
|---|---|---|---|---|---|
| Admin | SSO JWT + CRS permission (existing usman permission checks via launchpad_permission_check) | POST /users/sso_invite, GET /teams* | invite form + team field visible | no (own company) | invite events; team events (Kafka TopicTeamEventsV1) |
| Regular / read-only user | CRS permission denies | none of the above | team field not rendered; invite not reachable | no | n/a |
| Migration (trigger + source squads) | Launchpad trigger client + Chat/CRM service tokens calling /teams/bulk | trigger call + POST /teams/bulk | n/a | yes (squads push their own CIDs) | bulk upload-job results + TEAM_MIGRATED Kafka events |
| System (CID creation) | in-process (no actor) | CreateTeamForCompany | n/a | n/a — per new company | slog + team event |
PRD Section Coverage
| PRD § | Title | Where covered / n/a — reason |
|---|---|---|
| 3 | One-liner + Problem | §1 Overview |
| 4 | Target Users + Persona | §1.A Role Coverage |
| 5 | Non-Goals | §1 Out of Scope |
| Scope Changes | Backend/Frontend | §2.I Scope Boundaries |
| 6 / 6.1 | Constraints / Data Lifecycle | §3 (SLAs), §3.D (failure-log retention), §4.B (flags) |
| 7 | Feature Changes (CHG-001 URL) | §5 OQ-3 (n/a — out of repo) |
| 8 | New Features (mandatory dropdown) | §2.A, §2.C |
| 9 | API & Webhook Behavior | §2.4 |
| 10 | System Flow + Stories + ACs | §1.A, §2.1, §2.2, Detail 1.C |
| 11 / 11.1 | Rollout / Transition Window | §4 Rollout, §4.A |
| 12 / 12.1 | Observability / Cadence | §3 Monitoring |
| 13 | Success Metrics | §1 Success Criteria |
| 14 | Launch Plan & Stage Gates | §4 Rollout |
| 15 | Dependencies | §1 Dependencies, §2.F.1 |
| 16 | Key Decisions | §1.B, §2 Technical Decisions |
| 17 | Open Questions | §5 |
Detail 1.B — Decisions Closed (cross-layer)
| Decision | Chosen option | Alternatives rejected | Why rejected | Layer |
|---|---|---|---|---|
source_identifier storage | text + CHECK (source_identifier IN ('Chat','CRM','Launchpad')) | Postgres ENUM type | sqlc + golang-migrate codebase uses plain columns; an enum type adds migration friction to add values later; CHECK is queryable + type-safe enough (PRD §16) | BE |
| Idempotency key | unique partial index (company_id, source_identifier, reference_id) WHERE reference_id IS NOT NULL | dedupe by team name | names are prefixed/duplicable; reference_id is the stable source key (PRD §16) | BE |
| Migration model | push — each squad maps its own data and calls Launchpad's create endpoint; Launchpad triggers them | pull — Launchpad reads Chat/CRM data via their read APIs | keeps source-data ownership in each squad; reuses the existing is_migrate/app bulk path; avoids coupling Launchpad to Chat/CRM internal schemas (PRD §16, 2026-06-30) | both (cross-squad) |
| Migration write path | extend the existing POST /teams/bulk async path (EnqueueBulkCreateTeam/ProcessBulkCreateTeam) to persist source cols + reference_id idempotency | build a separate Launchpad-internal migrate command | the bulk path already validates app, prefixes names, processes async, and emits Kafka — reuse beats reinventing | BE |
| Migration kickoff | Launchpad triggers Chat/CRM via their migration-trigger endpoints (outbound client) | each squad self-triggers | PRD §9 #5 puts the trigger on Launchpad so kickoff is coordinated; trigger contract owned by the squads (OQ-1) | BE (cross-squad) |
| Source-id field naming | request/event field app_identifier_id (string), persisted into the teams.reference_id column | one reference_id name end-to-end | app/app_identifier_id is the source-app vocabulary the squads speak on the wire + in the mapping event; source_identifier/reference_id is Launchpad's storage vocabulary (app→source_identifier already established at create.go:84) — a clean, consistent request↔storage mapping | both (cross-squad) |
app_identifier_id requiredness | required when is_migrate=true; optional (ignored) for native creates | always optional | the idempotency index is partial (WHERE reference_id IS NOT NULL), so a missing app_identifier_id ⇒ NULL reference_id ⇒ no replay dedup (would duplicate on re-push, breaking OTM-S05/AC-3); mirror the existing IsMigrate && App=="" guard at team_request.go:34 | BE |
Migrate-mode member_ids | relax CreateTeam.Validate() to allow empty member_ids when is_migrate=true | keep member_ids required for all creates | migration carries team structures only, not memberships (PRD §5.1); the current validation (team_request.go:20) rejects empty member_ids, which would block every structure-only push | BE |
| Migration event | emit a separate TEAM_MIGRATED event for migrate-mode creates (instead of TEAM_CREATED), carrying team_id + app + app_identifier_id | reuse TEAM_CREATED for migrated teams | migrated teams need a back-mapping signal so each source squad can link its division/team to the centralized team; a distinct event_type lets Chat/CRM consume only migration mappings without re-handling normal creates; same envelope + topic (TopicTeamEventsV1) as the existing events | BE (cross-squad) |
| Invite team cardinality | accept teamIds: [], enforce ≥ 1 when flag on | single team_id | FE field is already multi-select (selectedTeamIds); accept array, require non-empty (reconciles §5 OQ-2) | both |
| Enforce flag read point | reuse existing GET /teams/menu-visibility (launchpad_show_team_menu) plus new launchpad_one_team_migration gate | brand-new FE flag fetch | menu-visibility already gates the field; layering the migration flag on the same BE check avoids a second FE flag mechanism (see §5 OQ-4) | both |
| General-team failure policy | keep non-fatal + add async retry job | make it fatal (roll back CID) | PRD OTM-S02/ERR-1 forbids rollback; retry closes the gap that today's log-only path leaves | BE |
| Team name casing | migrate to "General" (capital) per PRD | keep "general" | PRD OTM-S02 specifies "General"; reconcile casing — see §5 OQ-5 | BE |
| Soft vs hard delete | n/a — no deletes introduced | — | migration only inserts; no delete path added | BE |
| Inbound migration calls | Chat/CRM push into POST /teams/bulk (service-to-service) | Launchpad reads Chat/CRM data (pull) | push keeps source-data ownership with each squad; reuses existing bulk handler | BE (cross-squad) |
| FE↔BE casing/error shape | invite API is camelCase (the ApiUserInviteParam struct is tagless → Go case-insensitive JSON binds fullName/roleId/teamIds); FE sends camelCase as-is, no transform; errors use existing resp_desc.en | snake_case on invite | the existing invite payload already works camelCase end-to-end (verified sso_invite.go:23 + util.go:39); inventing snake_case would break the live contract | both |
Detail 1.C — Per-Story Change Map
| Story id | Title | Layer scope | FE changes | BE changes | Composite AC ids | Acceptance criteria (verifiable) | RFC anchors |
|---|---|---|---|---|---|---|---|
OTM-S01 | Mandatory team on invite | FE + BE | InviteUsers.vue yup teams → required (≥1) when enabled; fix useUsers.ts inviteUser to forward teamIds; error/empty/retry states | SsoInvite accepts teamIds, validates non-empty when flag on, writes team_users via CreateTeamUser; 400 on empty | OTM-S01/AC-1, AC-2, ERR-1, ERR-2, NEG-1 | vitest form test: submit blocked w/o team; go test SsoInvite writes N team_users rows; 400 body resp_desc.en == "Please select a team to continue" | §2.A · §2.4 row 1 · §4.D chunks 5–7 |
OTM-S02 | Auto-create General team on CID creation | BE-only (mostly built) | n/a — BE-only | stamp source_identifier='Launchpad', reference_id=NULL in CreateTeamForCompany (create.go:18); name "General"; add async retry on failure | OTM-S02/AC-1, AC-2, ERR-1 | go test MigrateCompany_GeneralTeam: created team has cols set; retry enqueued on injected failure | §2.3 · §2.4 row 4 · §2.F · §4.D chunk 4 |
OTM-S03 | Team sidebar URL change | Cross-squad | deferred — cross-squad (legacy CRM app) | n/a | OTM-S03/AC-1..3 | n/a — not in launchpad repos | §5 OQ-3 |
OTM-S04 | Track team source identifiers | BE-only | n/a | migration …_add_source_tracking_to_teams.up.sql; teams.source_identifier + reference_id + CHECK + unique index; update db/query/team.sql CreateTeam; regenerate team.sql.go; extend Team struct | OTM-S04/AC-1..4 | make migrate-up then \d teams shows cols + CHECK; insert of bad source value rejected; make migrate-down clean | §2.3 · §4.D chunks 1–2 |
OTM-S05 | Process pushed migration | BE-only + Cross-squad | n/a | extend request.CreateTeam/CreateTeamItem with app_identifier_id (required when is_migrate=true, REV-9) + relax member_ids to optional when is_migrate=true (REV-10, structures-only); createSingleTeam maps app→source_identifier, persists app_identifier_id→reference_id; reference_id idempotency (skip on conflict); emit TEAM_MIGRATED (with app+app_identifier_id+team_id) instead of TEAM_CREATED for migrate-mode; new trigger client methods (api/chat,api/crm). Chat/CRM own the mapping + active filter + the create calls + consuming TEAM_MIGRATED | OTM-S05/AC-1..4, ERR-1 | go test on create path: app=chat+app_identifier_id (no member_ids) ⇒ Chat - Support row with source_identifier='Chat'+reference_id; replay = 0 new rows (idempotent); TEAM_MIGRATED emitted carrying app_identifier_id | §2.4 rows (bulk/single + event + trigger) · §4.D chunk 3 · §5 OQ-1 |
OTM-S03 is intentionally retained (not dropped) as
Cross-squad/deferredso traceability stays complete — its absence from the launchpad repos is the finding, recorded in §5 OQ-3.
2. Technical Design
Detail 2.0 — Repo Reading Guide
Repo Map (mermaid, both layers)
flowchart LR
subgraph fe["qontak-launchpad-fe (Nuxt 4)"]
invite["features/user_management/users/invite/views/InviteUsers.vue"]
fieldteams["FieldSelectTeams.vue (field-teams)"]
useUsers["composables/useUsers.ts (inviteUser)"]
useTeams["composables/useTeams.ts (menu-visibility, list)"]
client["composables/useClient.ts ($fetch wrapper)"]
end
subgraph be["qontak-launchpad (Go)"]
router["server/rest_router.go (chi)"]
invsvc["service/users/sso_invite.go"]
teamsvc["service/teams (create.go, bulk_create.go)"]
cosvc["service/companies (migrate.go)"]
repo["repository (sqlc: team.sql.go)"]
bulk["bulk_create.go (EnqueueBulkCreateTeam / ProcessBulkCreateTeam)"]
trig["trigger client (api/chat, api/crm)"]
end
subgraph ext["source squads"]
chat(["Chat (maps own Divisions)"])
crm(["CRM (maps own Teams)"])
end
subgraph infra["infrastructure"]
db[("Postgres: teams, team_users")]
pref[("qontak-preferences")]
kafka[["Kafka TopicTeamEventsV1"]]
end
invite --> fieldteams --> useTeams --> client
invite --> useUsers --> client
client -->|"HTTPS via Kong"| router
router --> invsvc --> repo --> db
router --> teamsvc --> repo
cosvc --> teamsvc
trig -->|"trigger migrate"| chat
trig -->|"trigger migrate"| crm
chat -->|"POST /teams/bulk"| router
crm -->|"POST /teams/bulk"| router
router --> bulk --> teamsvc
invsvc --> pref
teamsvc --> kafka
Existing Code Anchors
| Layer | Path | Why the agent reads it | What pattern it teaches |
|---|---|---|---|
| BE | internal/app/repository/models.go:182 | Team struct to extend | sqlc model shape (no tags-driven ORM) |
| BE | db/migration/20250410080041_create_teams.up.sql | current teams DDL | golang-migrate up/down, BEGIN…COMMIT, Postgres |
| BE | db/query/team.sql (-- name: CreateTeam :one) | sqlc query to amend | sqlc query annotations, RETURNING * |
| BE | internal/app/repository/team.sql.go:40 | generated CreateTeamParams/CreateTeam | DO NOT EDIT; regenerate via sqlc |
| BE | internal/app/service/users/sso_invite.go:23 | invite entrypoint to extend | ApiUserInviteParam, validation, createLaunchpadUser |
| BE | internal/app/service/teams/create.go:18 | CreateTeamForCompany (General team) | where to stamp source cols |
| BE | internal/app/service/companies/migrate.go:148 | General-team call site (non-fatal) | failure policy to extend with retry |
| BE | internal/app/service/teams/bulk_create.go:25,75 | EnqueueBulkCreateTeam/ProcessBulkCreateTeam — the migration receive path | async (202 + upload job) → worker → createSingleTeam; BulkCreateTeamJobPayload |
| BE | internal/pkg/request/team_request.go:9 | CreateTeam/CreateTeamItem request to extend | already has IsMigrate/App (validated chat/crm); add AppIdentifierID string json:"app_identifier_id" (optional) here |
| BE | internal/app/service/teams/create.go:84 | migrate-mode name prefix | prefixedName := item.App + "-" + item.Name; this is where source_identifier/reference_id get persisted |
| BE | internal/server/rest_router.go:86,117 | route registration | chi r.Method(...), myHandler wrapper |
| BE | internal/pkg/http/default_error.go:8 | error envelope | BaseResponse{resp_code, resp_desc{id,en}, meta}, ErrBadRequest |
| BE | internal/pkg/constants/preferences.go:4 | flag constants | launchpad_* naming; add launchpad_one_team_migration |
| BE | internal/app/service/teams/get_team_menu_visibility.go:16 | flag read pattern | preference.IsEnabled(ctx, IsEnabledParams{FeatureName, UniqueID}) |
| BE | internal/app/service/teams/events.go:32,87,130 | team Kafka events to extend | TeamCreatedEvent envelope + maybePublishTeamCreatedEvent (EventType:"TEAM_CREATED", topic TopicTeamEventsV1); add a sibling TEAM_MIGRATED event/publisher carrying app+app_identifier_id |
| BE | cmd/seed_search_tokens.go:1 | cobra command template | pattern for the optional trigger-kickoff command (RootCmd.AddCommand, config.InitConfig()) if Launchpad initiates the trigger via CLI rather than an admin endpoint |
| BE | internal/app/api/chat/iChatClient.go:18 / api/crm/iCrmClient.go | outbound client pattern for the trigger call | heimdall httpclient + httpclientmw.LogMiddleware, timeout |
| BE | internal/app/api/chat/notify_team_update.go | placeholder client (log-only) | where a real trigger method would be added once Chat/CRM expose the endpoint |
| FE | app/features/user_management/users/invite/views/InviteUsers.vue:188 | invite form + team field | Pixel MpFormControl, vee-validate yup, onSubmit builds objToSend |
| FE | app/features/user_management/composables/useUsers.ts:294 | inviteUser store action | drops teamIds — the gap to fix |
| FE | app/features/user_management/composables/useTeams.ts:87,118 | fetchTeamMenuVisibility, fetchTeamList | isTeamMenuVisible, loading-status pattern |
| FE | app/common/composables/useClient.ts | API client | $fetch wrapper, {data,error}, 401 refresh |
Existing Contracts to Reuse, Extend, or Replace (BE)
| Contract | Status | Justification | Owner |
|---|---|---|---|
teams table | extend | add source_identifier, reference_id + CHECK + unique index | Bifrost |
db/query/team.sql CreateTeam | extend | add the two columns to the insert + a source-stamped variant | Bifrost |
POST /iag/v1/users/sso_invite | extend | add optional teamIds; enforce when flag on | Bifrost |
CreateTeamForCompany (teams/create.go) | extend | stamp source_identifier='Launchpad', reference_id=NULL; name "General" | Bifrost |
GET /iag/v1/teams, GET /teams/menu-visibility | reuse | invite field already consumes these | Bifrost |
POST /iag/v1/teams/bulk (+ POST /teams) | extend | already supports is_migrate/app + async + Kafka; add app_identifier_id, persist source_identifier+reference_id, add reference_id idempotency, emit TEAM_MIGRATED for migrate-mode | Bifrost |
request.CreateTeam / CreateTeamItem | extend | add app_identifier_id (json:"app_identifier_id", optional); app/is_migrate already present | Bifrost |
events.go team events | extend | add TEAM_MIGRATED event (sibling of TEAM_CREATED) carrying app+app_identifier_id+team_id | Bifrost |
| Chat/CRM migration-trigger client | new-with-justification | Launchpad must call each squad to kick off migration; only placeholder notify_team_update.go exists today — a trigger method must be added once the squads expose the endpoint (dependency) | Bifrost (calls Chat/CRM) |
qontak-preferences launchpad_one_team_migration | new-with-justification | no existing flag covers this rollout; reuse the existing preference.IsEnabled mechanism | Bifrost |
Patterns to Follow
| Layer | Concern | Pattern in repo | Reference file | Deviation? |
|---|---|---|---|---|
| BE | HTTP handler shape | myHandler(h.X.Method) returning (ResponseBody, error) | internal/pkg/http/handler.go:46, rest_router.go:86 | none |
| BE | DB access | sqlc-generated Queries methods | internal/app/repository/team.sql.go:50 | none |
| BE | Migrations | golang-migrate Postgres up/down, BEGIN…COMMIT | db/migration/20250306062703_create_team_users.up.sql | none |
| BE | Error shape | ErrBadRequest() → resp_desc.en | internal/pkg/http/default_error.go:24 | none |
| BE | Logging | slog.InfoContext(ctx, msg, slog.String(...)) | internal/app/service/teams/events.go:150 | none |
| BE | Feature flag | preference.IsEnabled | internal/app/service/teams/get_team_menu_visibility.go:44 | none |
| BE | Trigger kickoff (optional CLI) | cobra subcommand + RootCmd.AddCommand | cmd/seed_search_tokens.go:12 | only if Launchpad initiates the trigger via CLI vs an admin endpoint |
| BE | Outbound client | heimdall httpclient + log middleware + timeout | internal/app/api/chat/iChatClient.go:44 | none |
| FE | State mgmt | Pinia defineStore | composables/useTeams.ts:9 | none |
| FE | Validation | vee-validate + yup useForm({validationSchema}) | InviteUsers.vue:289 | extend teams rule to required |
| FE | Data fetch | useClient($fetch wrapper); apiBaseUrlKong | composables/useClient.ts, useUsers.ts:310 | none |
| FE | Toast/error | toast.notify({variant:'error', title}) | InviteUsers.vue:361 | none |
| Cross | invite API camelCase (tagless struct) ↔ camelCase FE | direct pass-through in store action body (no transform) | useUsers.ts:296 | add teamIds to paramsToSend (note: the bulk-migration endpoint uses snake_case app/app_identifier_id — different endpoint, intentional) |
Reading Order for the Agent
internal/app/repository/models.go:182— currentTeamshape.db/migration/20250410080041_create_teams.up.sql— DDL to extend.db/query/team.sql+internal/app/repository/team.sql.go:40— sqlc query/codegen.internal/app/service/teams/create.go:18—CreateTeamForCompany(General team).internal/app/service/companies/migrate.go:138-153— non-fatal General-team call site.internal/app/service/users/sso_invite.go:23-269— invite flow to extend.internal/server/rest_router.go:80-128— route registration.internal/pkg/request/team_request.go+service/teams/bulk_create.go+create.go— the existing migration receive path (is_migrate/app) to extend withreference_id+ source persistence.internal/app/service/teams/get_team_menu_visibility.go+internal/pkg/constants/preferences.go— flag mechanism.- FE:
InviteUsers.vue:188-410thenuseUsers.ts:294-339— the team-field + the dropped-payload gap.
Source Verification (anti-hallucination)
| Layer | Anchor / claim | Verified by | Evidence |
|---|---|---|---|
| BE | Team struct has no source cols | read | models.go:182-189 fields ID, CompanyID, Name, ParentID, CreatedAt, UpdatedAt only |
| BE | teams DDL columns | read | 20250410080041_create_teams.up.sql:3-18 (id, company_id, name, parent_id, created_at, updated_at) |
| BE | sqlc, not ORM | read | team.sql.go:1 "Code generated by sqlc … v1.31.1"; CreateTeamParams{CompanyID,Name,ParentID} at L44 |
| BE | migrate tool/dialect | read | Makefile:161-175 migrate -path db/migration -database "postgresql://…" |
| BE | invite has no team today | grep | grep team in sso_invite.go → 0 matches; ApiUserInviteParam lacks team field (L23) |
| BE | General team already created | read | companies/migrate.go:148 CreateTeamForCompany(ctx, newCompany.ID, param.CompanySsoID, "general"); impl teams/create.go:18 |
| BE | General-team failure is non-fatal, no retry | read | migrate.go:147 comment "(non-fatal)"; on error only slog.ErrorContext then continues (L149-153) |
| BE | teamCreator interface | read | companies/main.go:28-31 CreateTeamForCompany(ctx, companyID, companySSOID uuid.UUID, name string) |
| BE | route POST /sso_invite | read | rest_router.go:86 r.Method(http.MethodPost, "/sso_invite", myHandler(h.UserHandler.SsoInvite)) |
| BE | teams routes incl. /bulk, /menu-visibility | read | rest_router.go:117-128 |
| BE | error envelope | read | default_error.go:8-95 BaseResponse{ResponseCode, ResponseDesc{ID,EN}, Meta} |
| BE | flag mechanism + launchpad_* convention | read | get_team_menu_visibility.go:44 preference.IsEnabled; constants/preferences.go:4-27 (ShowTeamMenuKey="launchpad_show_team_menu"); no one_team_migration |
| BE | bulk migration path already exists | read | team_request.go:9 CreateTeam{IsMigrate, App}; create.go:84 prefixedName := item.App + "-" + item.Name; bulk_create.go:25,75 async enqueue/process; createSingleTeam → maybePublishTeamCreatedEvent |
| BE | Chat/CRM team integration is placeholder | read | api/chat/notify_team_update.go & api/crm/notify_team_update.go log-only return nil |
| BE | bulk-create is async/job-based | read | teams/bulk_create.go:25,75 EnqueueBulkCreateTeam/ProcessBulkCreateTeam |
| BE | test/build commands | read | Makefile:105 make test (go test -race -coverprofile), :120 make lint (staticcheck), :125 make sec (gosec), :50 make build, :161 make migrate-up |
| FE | Nuxt 4 / Vue 3 / Pixel3 / Pinia | read | package.json:50 nuxt ^4.4.2, :58 vue ^3.4.33, :64 @mekari/pixel3 1.0.8, :67-68 pinia |
| FE | team field exists, optional | read | InviteUsers.vue:191 v-if="teamsStore.isTeamMenuVisible"; :318 teams: yup.array().of(yup.string()) (no required) |
| FE | view passes teamIds but store drops them | read | InviteUsers.vue:356 teamIds: selectedTeamIds.value; useUsers.ts:296-306 paramsToSend omits teamIds |
| FE | invite endpoint path | read | useUsers.ts:310 POST ${apiBaseUrlKong}/users/sso_invite |
| FE | menu-visibility read | read | useTeams.ts:93 GET ${apiBaseUrlKong}/teams/menu-visibility → is_visible |
| FE | team URL change not in repo | grep | grep "qontakcrm/team|launchpad/team" app/ → 0 matches; real route app/pages/user_management/teams/index.vue |
| FE | test/build commands | read | package.json:16 test: vitest --dom --pool=forks, :12 lint:js eslint ., :19 type-check vue-tsc --noEmit, :7 build nuxt build; CI pnpm run test -- --run |
Design ↔ Code Mapping (frontend half)
| Figma frame / component | Implementing file | Reuse vs new | Tokens used | Backing API endpoint(s) | Deviation |
|---|---|---|---|---|---|
| Invite team field (required) | InviteUsers.vue:188 + FieldSelectTeams.vue | reused (extend to required) | Pixel MpFormControl/MpFormLabel/MpFormErrorMessage defaults | GET /teams/menu-visibility, GET /teams, POST /users/sso_invite | none — pixel-faithful; new required * marker only |
| Empty / error / loading states | InviteUsers.vue + store loading-status | reused | Pixel skeleton/toast | GET /teams | copy strings pending design (§3.C) |
Detail 2.1 — Architecture (mermaid)
End-to-end component diagram
flowchart TB
admin([Admin]) --> invite["InviteUsers.vue"]
invite --> useUsers["useUsers.inviteUser"]
useUsers --> client["useClient ($fetch)"]
client -->|"POST /users/sso_invite"| router["rest_router.go (chi)"]
router --> invsvc["UserService.SsoInvite"]
invsvc --> pref[("qontak-preferences")]
invsvc --> repo["repository (sqlc)"]
repo --> db[("teams, team_users")]
newcid([New CID]) --> cosvc["CompanyService.MigrateCompany"]
cosvc --> teamsvc["TeamService.CreateTeamForCompany"]
teamsvc --> repo
teamsvc --> kafka[["Kafka TopicTeamEventsV1"]]
trig["trigger client"] -->|"trigger migrate"| chat(["Chat squad (maps own data)"])
trig -->|"trigger migrate"| crm(["CRM squad (maps own data)"])
chat -->|"POST /teams/bulk (is_migrate, app, reference_id)"| router
crm -->|"POST /teams/bulk"| router
router --> bulk["ProcessBulkCreateTeam"] --> teamsvc
Data model (mermaid erDiagram)
erDiagram
COMPANIES ||--o{ TEAMS : has
TEAMS ||--o{ TEAM_USERS : has
USERS ||--o{ TEAM_USERS : has
TEAMS {
uuid id PK
uuid company_id FK
text name
uuid parent_id
text source_identifier "NEW: Chat|CRM|Launchpad"
text reference_id "NEW: nullable source id"
timestamptz created_at
timestamptz updated_at
}
TEAM_USERS {
uuid id PK
uuid user_id FK
uuid team_id FK
timestamptz created_at
}
State machine — teams.source_identifier
stateDiagram-v2
[*] --> Launchpad: native create or General team
[*] --> Chat: pushed migration from Chat Division
[*] --> CRM: pushed migration from CRM Team
Launchpad --> [*]
Chat --> [*]
CRM --> [*]
note right of Launchpad
reference_id is NULL
end note
note right of Chat
reference_id = division_id (immutable)
end note
Branch & skip flow — invite team enforcement
flowchart TD
trigger([Admin submits invite]) --> flag{"one_team_migration_enabled?"}
flag -- "no" --> legacy["team optional - proceed (legacy)"]
flag -- "yes" --> hasteam{"teamIds non-empty?"}
hasteam -- "no" --> reject["400 - Please select a team to continue"]
hasteam -- "yes" --> create["create user + write team_users"]
legacy --> done([invite complete])
create --> done
reject --> done
Detail 2.2 — Sequence (mermaid, end-to-end incl. failure paths)
Happy path — invite with mandatory team (flag on)
sequenceDiagram
actor A as Admin (FE)
participant LB as Kong gateway
participant API as launchpad-api (chi)
participant SVC as UserService.SsoInvite
participant PREF as qontak-preferences
participant DBW as Postgres primary
A->>LB: POST /users/sso_invite (email, role, teamIds)
LB->>API: HTTP
API->>SVC: SsoInvite(param incl teamIds)
SVC->>PREF: IsEnabled(launchpad_one_team_migration, cid)
PREF-->>SVC: true
SVC->>SVC: validate email/role + teamIds non-empty
SVC->>DBW: BEGIN - INSERT users - INSERT team_users x N - COMMIT
DBW-->>SVC: COMMIT ok
SVC-->>API: UserInfoResponse
API-->>A: 200 (invited, team assigned)
Failure path — invite with no team (flag on)
sequenceDiagram
actor A as Admin (FE)
participant API as launchpad-api
participant SVC as UserService.SsoInvite
participant PREF as qontak-preferences
A->>API: POST /users/sso_invite (no teamIds)
API->>SVC: SsoInvite(param)
SVC->>PREF: IsEnabled(launchpad_one_team_migration, cid)
PREF-->>SVC: true
SVC-->>API: ErrBadRequest (resp_desc.en)
API-->>A: 400 - Please select a team to continue
Happy path — push migration (Chat/CRM → Launchpad)
sequenceDiagram
participant TRIG as Launchpad trigger client
participant SQUAD as Chat / CRM squad
participant API as launchpad-api (POST /teams/bulk)
participant SVC as ProcessBulkCreateTeam
participant DBW as Postgres primary
participant K as Kafka TopicTeamEventsV1
TRIG->>SQUAD: trigger migrate (HTTPS)
Note right of SQUAD: squad maps its own ACTIVE Divisions/Teams
SQUAD->>API: POST /teams/bulk (is_migrate, app, reference_id, name, company_sso_id)
API->>SVC: enqueue + worker process
loop each item
SVC->>DBW: INSERT team (name prefix, source_identifier, reference_id=app_identifier_id) skip if (company,source,reference_id) exists
DBW-->>SVC: inserted or skipped (idempotent)
SVC->>K: publish TEAM_MIGRATED (team_id, app, app_identifier_id)
end
SVC-->>SQUAD: per-item results (upload job)
Note over K,SQUAD: squad consumes TEAM_MIGRATED to map app_identifier_id to launchpad team_id
Failure path — a source squad's migration fails
sequenceDiagram
participant TRIG as Launchpad trigger client
participant SQUAD as Chat / CRM squad
participant API as launchpad-api
TRIG->>SQUAD: trigger migrate (HTTPS)
alt squad unavailable / errors
SQUAD--xTRIG: error / no response
TRIG->>TRIG: log + record per-squad failure (no Launchpad rollback)
else item-level failure
SQUAD->>API: POST /teams/bulk (some bad items)
API-->>SQUAD: per-item results (failed items flagged, batch continues)
end
Happy/Failure — General team on CID creation
sequenceDiagram
participant CO as CompanyService.MigrateCompany
participant TS as TeamService.CreateTeamForCompany
participant DBW as Postgres primary
participant Q as retry queue (NEW)
CO->>DBW: INSERT company
DBW-->>CO: company row
CO->>TS: CreateTeamForCompany(General, source=Launchpad)
alt success
TS->>DBW: INSERT team (General, Launchpad, null)
DBW-->>TS: ok
else failure (non-fatal)
TS--xCO: error
CO->>CO: slog error (company NOT rolled back)
CO->>Q: enqueue retry (NEW)
end
Detail 2.3 — Database Model (DDL)
Migration db/migration/<YYYYMMDDHHmmss>_add_source_tracking_to_teams.up.sql (golang-migrate, Postgres — matches create_teams style):
BEGIN;
ALTER TABLE teams
ADD COLUMN IF NOT EXISTS source_identifier TEXT NOT NULL DEFAULT 'Launchpad',
ADD COLUMN IF NOT EXISTS reference_id TEXT;
ALTER TABLE teams
ADD CONSTRAINT chk_teams_source_identifier
CHECK (source_identifier IN ('Chat', 'CRM', 'Launchpad'));
-- idempotency key for migrated teams (native teams have NULL reference_id and are exempt)
CREATE UNIQUE INDEX IF NOT EXISTS idx_teams_source_ref_unique
ON teams (company_id, source_identifier, reference_id)
WHERE reference_id IS NOT NULL; -- supports OTM-S05/AC-3 (skip duplicates)
-- NOTE (REV-9): the index is partial on reference_id, so replay-safety requires a
-- non-NULL reference_id for migrated rows. `app_identifier_id` is therefore REQUIRED
-- when is_migrate=true (enforced in request validation), keeping migrated rows in-index.
COMMIT;
…_add_source_tracking_to_teams.down.sql:
BEGIN;
DROP INDEX IF EXISTS idx_teams_source_ref_unique;
ALTER TABLE teams DROP CONSTRAINT IF EXISTS chk_teams_source_identifier;
ALTER TABLE teams DROP COLUMN IF EXISTS reference_id;
ALTER TABLE teams DROP COLUMN IF EXISTS source_identifier;
COMMIT;
- Cardinality / growth: bounded by existing Divisions+Teams per CID × CIDs; one-time insert burst, then native-rate. No partitioning.
- Example rows:
('Chat - Support','Chat','4821'),('CRM - Sales','CRM','193'),('General','Launchpad',NULL). - PII:
namemay contain org-chosen labels (low sensitivity, already present);source_identifier/reference_idnon-PII. - Retention: team rows retained for company lifetime (existing policy). Migration failure logs retained 30 days (PRD §6.1) — see §3.D.
Per-status lifecycle (source_identifier):
| Value | Visibility | Retention | Restore semantics | Transitions allowed |
|---|---|---|---|---|
Launchpad | normal team list | company lifetime | n/a | terminal (not reclassified) |
Chat | normal team list | company lifetime | n/a — re-migrate is a no-op (idempotent) | terminal |
CRM | normal team list | company lifetime | n/a — re-migrate is a no-op | terminal |
- NoSQL alternative: rejected — teams already in Postgres; cross-table FK to
team_users.
Detail 2.4 — APIs
Outbound endpoints (consumers call us)
| Endpoint | Method | AuthN/AuthZ | Request schema | Response schema | Status codes | Idempotency | Versioning | Reuse? |
|---|---|---|---|---|---|---|---|---|
/iag/v1/users/sso_invite | POST | SSO JWT + CRS usman invite permission | {email, fullName, phone, roleId, nik, staffLevel, teamIds: string[]} — camelCase (binds to the tagless ApiUserInviteParam via Go case-insensitive JSON, sso_invite.go:23 + BindRequest util.go:39); adds field TeamIds []string (binds teamIds) | UserInfoResponse | 200; 400 (no team when enforced / invalid); 403 | natural (one SSO invitation per email/company) | path unchanged; additive field | extend |
/iag/v1/teams | GET | SSO JWT + perm | query: pagination | {data:[{id,name,...}]} | 200; 4xx | n/a (read) | unchanged | reuse |
/iag/v1/teams/menu-visibility | GET | SSO JWT + perm | — | {data:{is_visible:bool}} | 200 | n/a | unchanged (visibility now also reflects migration flag — see §5 OQ-4) | reuse/extend |
/iag/v1/teams/bulk | POST | service-to-service (Chat/CRM squad token) | {teams:[{company_sso_id, name, member_ids?, is_migrate, app, app_identifier_id}]} — adds app_identifier_id (required when is_migrate=true, REV-9); member_ids optional when is_migrate=true (REV-10, structures-only); is_migrate/app already exist | {upload_id, status:"pending", total} | 202; 400 (validation) | upload job + (company_id, source_identifier, reference_id) unique index | additive app_identifier_id + relaxed member_ids for migrate | extend |
/iag/v1/teams | POST | SSO JWT (UI) or service token (single migrate) | request.CreateTeam + app_identifier_id | CreateTeamResponse | 200; 400 | same unique index | additive app_identifier_id | extend |
POST /sso_invite example (flag on, success):
// request
{ "email": "ana@acme.com", "fullName": "Ana", "roleId": "role-123", "teamIds": ["b1f...","c2e..."] }
// 400 when teamIds empty and flag on
{ "resp_code": "400", "resp_desc": { "id": "Silakan pilih tim untuk melanjutkan", "en": "Please select a team to continue" }, "meta": {} }
- Rate limits / pagination: unchanged from existing endpoints.
Inbound webhooks (other services call us)
| Endpoint | Method | AuthN/AuthZ | Source | Schema | Codes | Idempotency | Versioning |
|---|---|---|---|---|---|---|---|
/iag/v1/teams/bulk | POST | service-to-service (Chat/CRM token) | Chat / CRM squad | {teams:[…app_identifier_id…]} (see Outbound row) | {upload_id,…} | 202; 400 | (company_id, source_identifier, reference_id) unique index |
In the push model, Chat and CRM call into Launchpad's
POST /teams/bulkto submit their mapped teams — so from Launchpad's side this is an inbound service-to-service call, not an outbound read. It is the same handler as the Outbound row above (listed in both places for the cross-squad reader).
Outbound — migration trigger (Launchpad → Chat/CRM)
| Call | Direction | Auth | Payload | Behaviour | Failure |
|---|---|---|---|---|---|
| Chat/CRM migration-trigger endpoint | Launchpad → squad (HTTPS) | service token | { ... — contract owned by Chat/CRM, §5 OQ-1 } | kicks off the squad's own mapping + push to /teams/bulk | heimdall timeout + retry; log + record per-squad failure; Launchpad not blocked |
Outbound event — TEAM_MIGRATED (Kafka, consumed by Chat/CRM)
Emitted by createSingleTeam for migrate-mode creates (is_migrate=true), in place of TEAM_CREATED. Same envelope + topic as the existing events (TopicTeamEventsV1, TeamCreatedEvent shape), gated by the existing FeaturePublishTeamUpdate preference. Its purpose is the back-mapping each source squad needs.
| Field | Value | Notes |
|---|---|---|
event_type | "TEAM_MIGRATED" | distinct from "TEAM_CREATED" so consumers subscribe only to migration mappings |
aggregate_id | Launchpad team.ID (UUID) | the centralized team id |
payload.team_name | Chat - <name> / CRM - <name> | as persisted |
payload.app | chat / crm | which source app |
payload.app_identifier_id | the source division_id / team_id | the join key — lets the squad map its own record → aggregate_id |
payload.company_sso_id, members, created_by_sso_id | as in TeamCreatedPayload | same fields as TEAM_CREATED otherwise |
Example:
{ "event_type": "TEAM_MIGRATED", "aggregate_id": "b1f2…", "aggregate_type": "team", "version": "1",
"occurred_at": "2026-07-01T03:00:00Z",
"payload": { "team_name": "Chat - Support", "app": "chat", "app_identifier_id": "4821",
"company_sso_id": "c2e…", "members": [], "created_by_sso_id": "" } }
Consumer side (Chat/CRM building
their_id ↔ launchpad_team_id) is owned by the source squads. Non-migration creates still emitTEAM_CREATEDunchanged.
Detail 2.A — UI Contract
- Component:
InviteUsers.vue(modify) +FieldSelectTeams.vue(field-teams, reuse). - Implementation file:
app/features/user_management/users/invite/views/InviteUsers.vue. - Change: make
teamsrequired (≥1) in the yup schema when team enforcement applies; render required*; keep emit@teams-selected="handleTeamsSelected"→selectedTeamIds. - State shape & ownership:
selectedTeamIds = ref<string[]>([])(InviteUsers.vue:334); team list fromuseTeamsStore(). - Validation:
yup.array().of(yup.string()).min(1, "Please select a team to continue")(gated; see §5 OQ-4 on how the gate reaches the FE). - Payload fix:
useUsers.ts inviteUsermust addteamIdstoparamsToSend(currently dropped atuseUsers.ts:296-306). - A11y: required field marked with
aria-required; error via existingMpFormErrorMessage.
Detail 2.B — Data-Fetching Strategy
- Library: custom
useClientwrapping$fetch(notuseFetch) —app/common/composables/useClient.ts. - Base URL:
useRuntimeConfig().public.apiBaseUrlKong. - Team list / visibility:
useTeams.fetchTeamList,fetchTeamMenuVisibility(cached onisTeamMenuVisibleboolean;forceto refetch). - No SWR / optimistic update for invite — single POST, toast on result.
Detail 2.C — UI State Matrix
| Surface | Loading | Empty | Error | Partial | Success |
|---|---|---|---|---|---|
| Team field on invite | skeleton/spinner while fetchTeamList pending | "No teams available. Create a team first." + submit disabled | "Could not load teams. Try again." + retry; submit disabled | n/a (single fetch) | populated; submit enabled when ≥1 selected (when enforced) |
Detail 2.D — Data Integrity Matrix
| Write path | Transaction scope | Partial failure | Idempotency key + TTL | Consistency | Duplicate handling | Stale read |
|---|---|---|---|---|---|---|
Invite → user + team_users | single DB transaction (user + N team_users atomic) | rollback all on any failure | team_users ON CONFLICT(user_id,team_id) DO NOTHING (existing CreateTeamUser) | strong | duplicate team selection deduped by unique index idx_team_user_unique | n/a |
Migration insert (pushed via /teams/bulk) | per-team insert in createSingleTeam | per-item result recorded, batch continues | unique index idx_teams_source_ref_unique; skip on conflict | strong (per row) | replay is a no-op (AC-3) | n/a |
| General team insert | independent of company insert (non-fatal) | company NOT rolled back; retry enqueued (NEW) | dedupe by name+company on retry | eventual (retry) | retry must check existence before insert | n/a |
Detail 2.E — Concurrency Collision Map
| Resource | Writers | Collision | Resolution | On conflict |
|---|---|---|---|---|
teams (migrated) | Chat/CRM re-push / parallel bulk jobs | two calls insert same (company_id, source, reference_id) | unique partial index + skip-on-conflict in createSingleTeam | second insert no-ops (idempotent) |
team_users (invite) | concurrent invites of same email | duplicate (user_id, team_id) | existing idx_team_user_unique + ON CONFLICT DO NOTHING | no-op |
teams General | CID-create + retry job | double General insert | retry checks existence by (company_id, name='General') first | skip if present |
Detail 2.F — Async Job / Event Consumer Spec
| Job | Trigger | Input | Retry | DLQ | Concurrency | Idempotency | Timeout | Poison handling |
|---|---|---|---|---|---|---|---|---|
| General-team retry (NEW) | General-team create failure in MigrateCompany | {company_id, company_sso_id} | 3 attempts, exp backoff (align to existing jobEnqueuer defaults — verify in internal/.../queue) | existing job DLQ | low | check (company_id,'General') before insert | per-job 30s | after max attempts → log general_team_create_exhausted + alert |
| Team Kafka events (existing) | team create/update/delete | TeamCreatedEvent{event_id,...} | existing | existing | existing | event_id | existing | existing |
TEAM_MIGRATED (NEW, producer) | migrate-mode create in createSingleTeam | TeamCreatedEvent-shaped + app+app_identifier_id, event_type="TEAM_MIGRATED" | producer publish (existing producer retry) | TopicTeamEventsV1 (no new topic) | n/a (per create) | aggregate_id (team_id) + app_identifier_id | existing producer | publish failure logged; team create still succeeds (same as TEAM_CREATED today, events.go:146) |
The retry job reuses the existing
jobEnqueuer/queuewiring already onTeamService(teams/main.gojobEnqueuer); exact backoff constants must be read from the queue package before coding (§4.D chunk 4).
Detail 2.F.1 — Responsibility Boundary Matrix
| Step (exec order) | Owning squad / service | Inbound trigger | Outbound effect | Failure handler | PRD anchor |
|---|---|---|---|---|---|
| 0. Trigger each squad's migration | Bifrost (trigger client) | kickoff (flag on) | HTTPS trigger to Chat/CRM | log + record per-squad failure; not blocked | PRD §9 #5 |
| 1. Map own active Divisions + push | Chat squad | Launchpad trigger | POST /teams/bulk (app=chat, app_identifier_id) | squad retries on its side | PRD §15 |
| 2. Map own active Teams + push | CRM squad | Launchpad trigger | POST /teams/bulk (app=crm, app_identifier_id) | squad retries on its side | PRD §15 |
| 3. CRM-side migration done before Phase 2 | CRM squad | rollout gate | CRM teams migrated | hold Phase 2 | PRD §11/§15 |
4. Process pushed teams + persist source cols + emit TEAM_MIGRATED | Bifrost (ProcessBulkCreateTeam/createSingleTeam) | inbound /teams/bulk | teams rows + TEAM_MIGRATED (with app_identifier_id) | per-item result; idempotent skip | PRD §10 OTM-S05 |
5. Consume TEAM_MIGRATED + map own division/team → Launchpad team | Chat / CRM squads | TEAM_MIGRATED Kafka | each squad's own id↔team_id mapping | squad-side retry | this case's requirement |
| 5. Enforce team on invite | Bifrost (SsoInvite) | Admin invite | team_users row | 400 | PRD §10 OTM-S01 |
| 6. General team on CID create | Bifrost (MigrateCompany) | new CID | General team | non-fatal + retry | PRD §10 OTM-S02 |
| 7. Team sidebar URL change | legacy CRM app squad (NOT launchpad) | navigation | route/redirect | n/a | PRD §7 / §5 OQ-3 |
Step 7 disagrees with the PRD's implied Bifrost ownership: the routes are not in either launchpad repo. Recorded as §5 OQ-3, not silently absorbed.
Detail 2.F.2 — State Surface Contract
| Entity | State field / event | Default | Updated by | Read via | Stale window |
|---|---|---|---|---|---|
| Team | source_identifier | 'Launchpad' | migration / CreateTeamForCompany / native create | GET /teams | none (write-time) |
| Team (migrated) | TEAM_MIGRATED (Kafka, carries app_identifier_id) + per-item bulk result | — | ProcessBulkCreateTeam/createSingleTeam | Kafka TopicTeamEventsV1 + bulk upload-job status | n/a |
| Team (native create) | TEAM_CREATED (Kafka) | — | createSingleTeam | Kafka TopicTeamEventsV1 | n/a |
| Invite | user_invited_with_team event | — | SsoInvite | logs | n/a |
Detail 2.G — Cross-Layer Contract Verification
| Endpoint | BE response schema | FE expected schema | Match? | Gaps |
|---|---|---|---|---|
POST /users/sso_invite | accepts teamIds: string[] (tagless TeamIds field, camelCase via case-insensitive JSON); returns UserInfoResponse; 400 resp_desc.en | FE sends teamIds; reads resp_desc.en for error | yes (contract aligned — both camelCase teamIds) | only the FE store currently drops teamIds before POST (useUsers.ts:296) — a wiring bug closed by §4.D chunk 6 (forward the field as-is, no casing transform) |
GET /teams/menu-visibility | {data:{is_visible:bool}} | is_visible → isTeamMenuVisible | yes | visibility must also reflect launchpad_one_team_migration (§5 OQ-4) |
GET /teams | {data:[{id,name,...}]} | team list | yes | none |
The invite contract is aligned (both sides camelCase
teamIds); the only residual is the FE store wiring closed by §4.D chunk 6; tracked as the cross-layer blocker until that chunk lands.
Detail 2.H — End-to-End Data Flow
Invite with team: Admin selects team(s) in field-teams → selectedTeamIds → onSubmit builds objToSend (InviteUsers.vue:351) → inviteUser must forward teamIds (FIX) → useClient POST /users/sso_invite → Kong → chi rest_router.go:86 → UserService.SsoInvite → flag check (preference.IsEnabled) → validate teamIds non-empty → BEGIN; createLaunchpadUser; CreateTeamUser×N; COMMIT → UserInfoResponse → FE success toast.
- Side effects: invite events (
user_invited_with_team); existing SSO invitation side-effects unchanged. - Ownership: FE (Bifrost), API+service (Bifrost), DB (Bifrost).
Detail 2.I — Scope Boundaries
- BE create:
db/migration/…_add_source_tracking_to_teams.{up,down}.sql; Chat/CRM migration-trigger client methods (underinternal/app/api/chat,internal/app/api/crm);launchpad_one_team_migrationconstant ininternal/pkg/constants/preferences.go. - BE modify:
db/query/team.sql(+ regeneratedteam.sql.go);repository/models.goTeam;internal/pkg/request/team_request.go(CreateTeam/CreateTeamItem+app_identifier_id);service/teams/create.gocreateSingleTeam(mapapp→source_identifier, persistapp_identifier_id→reference_id, reference_id idempotency, emitTEAM_MIGRATEDfor migrate-mode) +CreateTeamForCompany(stamp cols,"General");service/teams/events.go(TEAM_MIGRATEDevent + publisher);service/users/sso_invite.go(ApiUserInviteParam+ write);service/companies/migrate.go+migrate_full.go(retry enqueue). - BE NOT touched: existing Chat/CRM
notify_team_update.go(placeholder, leave as-is); billing/quota;team_usersschema. - FE modify:
InviteUsers.vue(required rule);useUsers.ts(teamIdspayload). - FE NOT touched: team management pages/routes; sidebar URLs (OTM-S03 out of repo).
- Shared impact:
CreateTeamsqlc signature changes — every caller (teams/create.go,bulk_create.go) must pass the new cols; impact-checked in §4.D chunk 2.
Detail 2.J — Asset Inventory
| Asset | Type | Source | Format | Path |
|---|---|---|---|---|
| n/a — no new icons/illustrations | — | reuse Pixel MpFormControl | — | — |
3. High-Availability & Security
The runtime change is additive (two columns, an additive invite field, an extended bulk-create path + a trigger). The invite path stays a single synchronous request; the migration is an async push the source squads drive (existing EnqueueBulkCreateTeam → worker). Graceful degradation: if the team-list read fails, the FE shows a retry and disables submit (§2.C); if a source squad's migration fails, its items are flagged in the bulk result / the squad retries, and Launchpad is not blocked.
Performance Requirement
- Invite team validation adds one
preference.IsEnabledcall + Nteam_usersinserts in the same transaction — well within PRD's ≤ 500ms (PRD §6). - General-team creation already in the CID-create path; PRD ≤ 2s — retry is async, off the critical path.
- Migration: PRD target all active CIDs within 24h;
--batch-size(default 1000, perseed-*precedent) bounds memory.
Monitoring & Alerting
- Kafka:
TEAM_MIGRATED(topicTopicTeamEventsV1) per migrated team, carryingteam_id+app+app_identifier_id— the back-mapping signal for Chat/CRM. - Events / logs (slog, matching
events.gofield style):team_migrated{cid,source_identifier,reference_id (=app_identifier_id),team_name},team_migration_failed{cid,source,reason},user_invited_with_team{cid,team_id,source_identifier},invitation_rejected_no_team{cid,admin_id},general_team_created{cid,team_id}(PRD §12). - Alerts (PRD §12):
team_migration_failedrate > 1% per run →#bifrost-alerts;general_team_createdfailure > 0 in 24h →#bifrost-alerts. - Tracing: existing DataDog chi middleware (
rest_router.go:39) covers the invite endpoint automatically.
Logging
- Structured
slogwithslog.String("cid",…)etc.; no raw PII in migration logs (logreference_id, not member data).
Security Implications
- Threat model: migration writes span all CIDs by design — but now over
POST /teams/bulkfrom Chat/CRM, so it must be a service-to-service authenticated endpoint (not the user SSO scope), not publicly exposed, and the trigger client uses service creds. Invite path keeps existing per-company SSO/CRS authorization (no cross-tenant team assignment: validate selectedteamIdsbelong to the inviter's company). - Input validation:
teamIdsare UUIDs and must resolve to teams in the actor'scompany_id(enforce inSsoInvitebefore write). - Injection: sqlc parameterized queries (no string-built SQL).
- Secrets: Chat/CRM client creds via existing config (heimdall clients already take
username/password/clientID/clientSecret). - Static analysis:
make sec(gosec) in CI.
Role × Endpoint Authorization Matrix
| Role | Endpoint(s) | Methods | Tenant scope | UI visibility | Constraint | Audit |
|---|---|---|---|---|---|---|
| Admin | /users/sso_invite, /teams* | POST/GET | own company | invite + team field | teamIds must be own-company | invite events |
| Regular/read-only | — | — | — | hidden | denied by CRS | n/a |
| Migration (Chat/CRM push) | POST /teams/bulk | n/a | all CIDs (squads push their own) | n/a | service-to-service token | bulk results + Kafka events |
| System (CID create) | CreateTeamForCompany | in-process | per new company | n/a | n/a | slog + team event |
Detail 3.A — Failure Mode Catalog (merged)
| Surface | FE behavior on failure | BE response | Code-shape consistent? |
|---|---|---|---|
| Invite, no team (flag on) | inline error "Please select a team to continue"; submit blocked | 400 resp_desc.en | yes |
| Team list load | "Could not load teams. Try again." + retry; submit disabled | GET /teams 4xx/5xx | yes |
| Source API down (migration) | n/a (CLI) | CID skipped, logged, continue, exit 0 | n/a |
| General-team create fail | n/a | company kept; retry enqueued | n/a |
Detail 3.A.1 — Branch & Skip Catalog
| Branch trigger | Where checked | Downstream effect | Audit | User-visible? |
|---|---|---|---|---|
one_team_migration_enabled = false | SsoInvite + FE visibility | team optional, no enforcement | none | yes (no required *) |
| Existing user without team (update) | invite/update paths | no enforcement, no error (OTM-S01/NEG-1) | none | no |
| Inactive/archived Division/Team | migration source filter | record skipped | log | no |
reference_id already present | unique index | insert no-op (idempotent) | optional log | no |
Detail 3.B — Error Response Catalog (BE)
Envelope: { "resp_code": "...", "resp_desc": {"id":"...","en":"..."}, "meta": {} } (default_error.go).
| Endpoint | Code | HTTP | Message (en) | When | User-facing? |
|---|---|---|---|---|---|
/users/sso_invite | 400 | 400 | Please select a team to continue | flag on, teamIds empty | yes |
/users/sso_invite | 400 | 400 | Invalid request | malformed teamIds / cross-tenant team | yes |
Detail 3.C — Error Message Catalog (FE)
| Error | User-facing message | Surface | User-facing? |
|---|---|---|---|
| no team selected | "Please select a team to continue" | inline (MpFormErrorMessage) | yes |
| team list load failed | "Could not load teams. Try again." + retry | inline/field | yes |
| no teams exist | "No teams available. Create a team first." | inline; submit disabled | yes |
Final copy pending design sign-off (PRD §8); does not block coding.
Detail 3.D — Compliance & Data Governance
| Field | Classification | Legal basis | Retention | Encryption | Access audit | Right-to-delete |
|---|---|---|---|---|---|---|
teams.name | low — org label | n/a | company lifetime | at rest (DB), TLS in transit | team events | follows company deletion |
| Migration failure log | operational | n/a | 30 days (PRD §6.1) | at rest, TLS | engineer-only | nightly cleanup |
No payment/health data. Member-to-team data is not migrated (out of scope), reducing PII exposure.
Detail 3.E — Accessibility
- Required team field:
aria-required="true"; error announced viaMpFormErrorMessage; keyboard-navigable Pixel components; WCAG AA via design system defaults.
4. Backwards Compatibility and Rollout Plan
Compatibility
- DDL: additive columns with safe defaults (
source_identifier DEFAULT 'Launchpad',reference_id NULL) — existing rows valid immediately; no backfill needed. - Invite API:
teamIdsadditive/optional; old FE without the field still works while flag off. - FE saved state: none affected.
Rollout Strategy
- Migration sequence: (1) ship DDL (
make migrate-up); (2) deploy BE (invite acceptsteamIds, General-team stamps cols, retry job); (3) deploy FE (required rule +teamIdspayload) behind flag; (4) provisionlaunchpad_one_team_migrationper the staged audiences; (5) trigger Chat/CRM migration per enabled CID batch — each squad maps + pushes into/teams/bulk(after CRM-side migration completes for Phase 2). - Schema during migration: columns exist with defaults before any code reads them — no intermediate broken state.
- Feature flag:
launchpad_one_team_migration(global, default false); kill-switch = set false (reverts to optional team, no redeploy). - Stages (audience only; schedule lives in
delivery/): internal QA CIDs → staged CID batches (post CRM migration) → GA allunified_appCIDs (PRD §11/§14). - Rollback trigger:
team_migration_failed> 1%, or invite 400 spike post-enable.
Detail 4.A — Cross-Layer Rollout Compatibility Matrix
| Scenario | FE | BE | Works? | Mitigation |
|---|---|---|---|---|
| Pre-deploy | Old | Old | yes | baseline (team optional, dropped payload) |
| Backend first | Old | New | yes | New BE accepts but doesn't require teamIds until flag on; old FE unaffected |
| Frontend first | New | Old | partial | New FE sends teamIds; old BE ignores unknown field (additive) — no enforcement yet. Acceptable; avoid enabling flag until BE deployed |
| Both deployed, flag off | New | New | yes | optional team (legacy behavior) |
| Both deployed, flag on | New | New | yes | target state: enforced |
| Backend rollback | New | Old | partial | FE still sends teamIds (ignored); disable flag first |
| Frontend rollback | Old | New | yes | BE only enforces if flag on; turn flag off to be safe |
Deploy order: BE before FE; enable flag last. Any rollback disables the flag first.
Detail 4.B — Configuration Contract
| Layer | Env var / flag | Type | Default | Required | Provisioner | Secret? |
|---|---|---|---|---|---|---|
| BE | launchpad_one_team_migration (qontak-preferences) | bool (global) | false | yes (provision) | Bifrost via preferences service | no |
| BE | Chat/CRM trigger client base URL + creds | string | — | yes (once trigger endpoint exists) | config (existing pattern) | yes (creds) |
| FE | reads visibility via GET /teams/menu-visibility | — | — | no new FE flag | — | no |
Detail 4.C — Test Plan (commands sourced from repo)
| Layer | Command (source) | Must prove |
|---|---|---|
| BE unit | make test (Makefile:105 → go test -race -coverprofile=coverage.out ./internal/app/...) | SsoInvite writes team_users; rejects empty when flag on; General-team stamps cols; migrate path idempotent |
| BE migration | make migrate-up / make migrate-down (Makefile:161/168) | columns + CHECK + index added and cleanly reverted |
| BE lint | make lint (Makefile:120, staticcheck) | clean |
| BE security | make sec (Makefile:125, gosec) | clean |
| BE bulk-create migrate path | make test (covers ProcessBulkCreateTeam/createSingleTeam) | app=chat+app_identifier_id ⇒ team Chat - X with source_identifier='Chat'+reference_id; replay = 0 new (idempotent); TEAM_MIGRATED published with app_identifier_id |
| FE unit | pnpm run test -- --run (package.json:16 vitest --dom; CI bitbucket-pipelines.yml) | submit blocked w/o team (flag on); teamIds present in posted body |
| FE lint | pnpm run lint (package.json:11-13, eslint+prettier) | clean |
| FE typecheck | pnpm run type-check (package.json:19, vue-tsc --noEmit) | clean |
| Cross-layer | manual/integration: invite from FE against BE with flag on/off | 400 vs success; team_users row created |
No e2e framework is configured in
qontak-launchpad-fe(no playwright/cypress) — cross-layer coverage is unit + manual integration. Recorded so coverage isn't overstated (initiative is QA Lane B).
Detail 4.D — Agent Execution Plan
| Order | Layer | Chunk | Files | Commands | Acceptance criteria |
|---|---|---|---|---|---|
| 1 | BE | Add source columns migration | db/migration/<ts>_add_source_tracking_to_teams.{up,down}.sql | make migrate-up; make migrate-down; make migrate-up | \d teams shows source_identifier,reference_id,chk_*,idx_teams_source_ref_unique; down reverts clean |
| 2 | BE | sqlc query + model | db/query/team.sql (CreateTeam + source-stamped insert), regenerate internal/app/repository/team.sql.go, repository/models.go Team | make mocks (if needed); make test | code compiles; CreateTeamParams carries source cols; existing callers updated |
| 3 | BE | Extend bulk/single create (persist source + idempotency + TEAM_MIGRATED); add trigger client | internal/pkg/request/team_request.go (+app_identifier_id; require it when is_migrate=true mirroring the IsMigrate && App=="" guard at :34; allow empty member_ids when is_migrate=true at :20/:55); service/teams/create.go (createSingleTeam maps app→source_identifier, persists app_identifier_id→reference_id, skip-on-conflict, emits TEAM_MIGRATED); service/teams/events.go (TEAM_MIGRATED event+publisher); trigger methods in internal/app/api/chat,/crm | make test; make lint | migrate item w/o member_ids accepted; migrate item w/o app_identifier_id rejected (400); app=chat+app_identifier_id ⇒ Chat - X row w/ source cols; replay = 0 new; TEAM_MIGRATED emitted carrying app_identifier_id+team_id; trigger client compiles (endpoint contract stubbed pending OQ-1) |
| 4 | BE | General-team source cols + retry | service/teams/create.go, service/companies/migrate.go, migrate_full.go | make test | MigrateCompany_GeneralTeam test: team has source='Launchpad',reference_id=NULL, name "General"; injected failure enqueues retry, company not rolled back |
| 5 | BE | Invite team enforcement | service/users/sso_invite.go (ApiUserInviteParam.TeamIds, validate, CreateTeamUser), flag const internal/pkg/constants/preferences.go | make test; make lint | flag on + empty ⇒ ErrBadRequest resp_desc.en; flag on + teams ⇒ team_users rows; flag off ⇒ unchanged; cross-tenant team rejected |
| 6 | FE | Forward teamIds to API | composables/useUsers.ts (inviteUser includes teamIds in paramsToSend — no casing transform) | pnpm run type-check; pnpm run test -- --run | posted body includes teamIds; unit asserts payload |
| 7 | FE | Required team rule + states | InviteUsers.vue (yup .min(1) gated; empty/error/loading states) | pnpm run test -- --run; pnpm run lint | submit blocked w/o team when enforced; error/retry rendered; success toast on resolve |
Chunk order respects deps: DDL (1) → codegen (2) → command (3) and General team (4) depend on cols; invite (5) before FE (6,7); enable flag only after 1–7 deployed.
Detail 4.E — Verification & Rollback Recipe
- Pre-merge (BE), in order:
make lint→make sec→make migrate-up→make test→make build. - Pre-merge (FE), in order:
pnpm run lint→pnpm run type-check→pnpm run test -- --run→pnpm run build. - Post-deploy signals:
#bifrost-alertsclean;invitation_rejected_no_teamnot spiking after flag-on;team_migration_failedrate < 1% in the run summary;general_team_createdfailures = 0 in 24h (DataDog logs). - Rollback recipe (deploy-order-aware):
- Set
launchpad_one_team_migration = false(instant; invite reverts to optional). - If FE regression: revert the FE PR (BE tolerates
teamIdsabsence). - If BE regression: revert BE PR; columns are additive and safe to leave (only
make migrate-downif the DDL itself is faulty — note: dropping columns is destructive of migratedsource_identifier/reference_id, so prefer leaving columns and only reverting code). - Confirm invite success rate recovered and
#bifrost-alertsclear.
- Set
Detail 4.F — Resource & Cost Notes
- Compute: negligible (two columns; one extra flag read + N small inserts per invite; offline batch).
- DB: one-time insert burst at migration; storage growth bounded by existing Division/Team counts.
- Network: Launchpad makes outbound trigger calls to Chat/CRM; Chat/CRM make inbound
POST /teams/bulkcalls (rate-limit-aware, heimdall timeouts on the trigger).
5. Concern, Questions, or Known Limitations
Findings tagged
REV-nwere raised by therfc-reviewersecond pass (R1, 2026-06-30) — seerfc-qontak-one-team-migration-review.md.
| # | Type | Question / limitation | Owner | Blocking? |
|---|---|---|---|---|
| OQ-1 (REV-1) | TECHNICAL | Migration-trigger contract + push agreement. Migration is push (PRD v1.2): Launchpad's receive path (POST /teams/bulk, is_migrate/app) already exists, so the Launchpad side of chunk 3 (persist source cols + reference_id idempotency) is buildable now. What's undefined: (a) the Chat/CRM migration-trigger endpoint Launchpad calls (path, auth, payload), and (b) the agreement that each squad maps active records, sends app_identifier_id, and consumes TEAM_MIGRATED. The trigger client and full end-to-end migration cannot complete until (a)/(b) land. (PRD §15; §17 OQ1 data-volume also lands here.) | Chat + CRM squads | YES (trigger + push contract) |
| OQ-2 (REV-5) | PRODUCT/TECH | Invite team cardinality: PRD says team_id (singular); FE field is multi-select (selectedTeamIds). This RFC specifies an array requiring ≥1. Confirm single vs multi is intended. (PRD §17 OQ2 dual-team users relates.) | Bifrost PM | YES (shapes API) |
| OQ-3 (REV-8) | TECHNICAL | OTM-S03 team sidebar URL /qontakcrm/team → /launchpad/team is absent from both launchpad repos (real route /user_management/teams). It belongs to the legacy CRM app. Confirm owning squad/repo; this RFC excludes it. PRD/ANCHOR still list it Must-Have — reconcile scope. | Bifrost PM + CRM squad | no (out of scope here) |
| OQ-4 (REV-3) | TECHNICAL | How does launchpad_one_team_migration reach the FE enforcement? Option A: BE folds it into GET /teams/menu-visibility (is_visible) so FE needs no new flag fetch (preferred). Option B: new FE flag read. Decide before chunk 7. | Bifrost | YES (FE gating) |
| OQ-5 | TECHNICAL | Team name casing: existing General team is created as "general" (migrate.go:148); PRD specifies "General". Confirm rename + whether existing "general" rows are normalized. | Bifrost | no |
| OQ-6 (REV-2) | TECHNICAL | ✅ RESOLVED (2026-06-30). Verified: ApiUserInviteParam (sso_invite.go:23) has no JSON tags, and BindRequest (util.go:39) uses standard json.Decode → Go matches field names case-insensitively, so the endpoint binds camelCase (fullName/roleId/teamIds; full_name would not bind). §2.4/§2.G/§2.A corrected to teamIds; the new BE field is TeamIds []string (tagless, binds teamIds). Note: a different struct UserCreate uses json:"team_id" — intentionally not followed here (different endpoint). | Bifrost | resolved |
| OQ-7 (REV-4) | TECHNICAL | General-team async retry policy (attempts, backoff, DLQ, exhausted-alert) is deferred to "existing jobEnqueuer defaults — verify" (§2.F). Read the queue package and pin these before chunk 4. | Bifrost | no |
| OQ-8 (REV-6) | TECHNICAL | /sso_invite success response schema is only named (UserInfoResponse), not pinned in §2.4. Inline the fields the FE reads (FE currently reads only resp_desc.en on error). | Bifrost | no |
| OQ-9 (REV-9) | TECHNICAL | ✅ RESOLVED (idempotency). Decided: app_identifier_id is required when is_migrate=true (§1.B + §2.3 note + §4.D ch3), so migrated rows always have a non-NULL reference_id and stay in the partial unique index → replay-safe. Residual (still open): Chat/CRM event sign-off — (a) same topic TopicTeamEventsV1 with event_type filtering vs a dedicated topic; (b) payload fields (team_id+app+app_identifier_id). | Bifrost + Chat/CRM | no (idempotency closed; topic sign-off pending) |
| OQ-10 (REV-10) | TECHNICAL | ✅ RESOLVED. Decided: relax CreateTeam.Validate() to allow empty member_ids when is_migrate=true (§1.B + §4.D ch3), consistent with PRD §5.1 (structures only, not memberships). Native (non-migrate) creates still require member_ids. | Bifrost | resolved |
| L-1 | LIMITATION | Migration trigger is engineer-run (ANCHOR OQ3), not auto on flag toggle — operational runbook needed. | Bifrost | no |
| L-2 (REV-7) | LIMITATION | No e2e framework in FE repo; cross-layer verification is unit + manual (QA Lane B). FE perf budget / analytics events also unstated (enhancement — low risk). | Bifrost QA | no |
6. Comment logs
| Date | Comment(s) From | Action Item(s) |
|---|---|---|
| 2026-06-30 | RFC author (Claude, via rfc-starter) | Initial full-stack draft grounded in live qontak-launchpad (main) + qontak-launchpad-fe (master) worktrees. All 10 mermaid blocks validated with mmdc (exit 0, no parse errors). Surfaced 4 PRD-vs-code deltas (OTM-S02 already built; FE payload gap; OTM-S03 out of repo; Chat/CRM read APIs absent). |
| 2026-06-30 | rfc-reviewer (R1) | Reviewed; HOLD @ 6.5. See -review.md. Findings REV-1..8 promoted to §5. |
| 2026-06-30 | R2 fixes applied | Closed REV-2/REV-9/REV-10 in spec. REV-2: verified ApiUserInviteParam is tagless (sso_invite.go:23) + BindRequest uses case-insensitive json.Decode (util.go:39) ⇒ endpoint binds camelCase; corrected §2.4/§2.G/§2.A/§1.B to teamIds (field TeamIds []string), removed the snake_case mapping. REV-9: app_identifier_id now required when is_migrate=true (replay-safe vs the partial unique index). REV-10: member_ids relaxed to optional when is_migrate=true (structures-only per PRD §5.1). Updated §1.B decisions, §2.3 DDL note, §2.4, OTM-S05, §4.D ch3/ch6, §5 OQ-6/9/10, §7. |
| 2026-06-30 | rfc-reviewer (R2) | Re-reviewed push-model revision; HOLD @ 6.5 (cap = REV-2 casing). REV-1 eased blocker→major; 2 new findings: REV-9 (optional app_identifier_id + partial index ⇒ no replay dedup → OQ-9) and REV-10 (member_ids required vs structure-only non-goal → OQ-10). See -review.md. |
| 2026-06-30 | Migration-mapping refinement | Added app_identifier_id (optional string) to the bulk/single create request (persists into the teams.reference_id column); migrate-mode now emits a separate TEAM_MIGRATED Kafka event (instead of TEAM_CREATED) carrying team_id+app+app_identifier_id, so each source squad can map its own division/team → the centralized Launchpad team. Touched §1 overview/deltas/deps/PRD-to-schema, §1.B/C, §2.0 anchors/contracts, §2.2 sequence, §2.4 (+event contract), §2.F/F.1/F.2, §2.I, §3, §4.D, §5 OQ-9. |
| 2026-06-30 | Flow correction (per planning) | Migration model changed pull → push to match service planning: each source squad maps its own data and calls Launchpad's existing POST /teams/bulk (is_migrate/app already present); Launchpad triggers them, persists source cols + reference_id idempotency, emits Kafka. Reworked §1 deltas/deps/assumptions/PRD-to-schema, §1.A/B/C, §2.0 anchors/contracts/repo-map, §2.1 component diagram, §2.2 migration sequences, §2.4 APIs, §2.D/E/F.1/F.2, §2.I, §4.C/D, §5 OQ-1, §7. Review R1 is now stale — re-review (R2) recommended. |
7. Ready for agent execution
- no — blocked on cross-squad contracts. The Bifrost-owned chunks (1, 2, 4, 5, 6, 7) are fully specified and executable today; chunk 3 (migration command) is blocked.
Missing execution-readiness gates:
Resolved since R2 (now closed in spec; code lands in §4.D): REV-2 (invite casing → teamIds, verified tagless binding), REV-9 (app_identifier_id required for migrate-mode → replay-safe), REV-10 (relax member_ids for migrate-mode). The §2.G invite row is now Match? = yes.
Remaining gates:
- §5 OQ-1 / REV-1 (blocking for end-to-end): the push receive path (
POST /teams/bulk) already exists and the Launchpad-side extensions (chunks 1–5) are now fully specified and buildable; what remains is the cross-squad migration-trigger contract + theTEAM_MIGRATEDtopic sign-off (OQ-9 residual) + the squads' own mapping/push/consume work. - §5 OQ-2 / REV-5 (decide before chunk 5): confirm single vs multi team on invite (this RFC specifies
teamIds: string[]). - §5 OQ-4 / REV-3 (decide before chunk 7): how
launchpad_one_team_migrationreaches the FE gate (Option A recommended). - Cross-Layer Contract Verification (§2.G): invite contract now aligned (both camelCase
teamIds); only the FE store wiring remains (chunk 6).
All other gates are met: Infra/component/ER/state/branch/sequence diagrams present and parser-validated; DDL with per-status lifecycle traces to §1 PRD-to-Schema; APIs tagged reuse/extend/new; Source Verification complete with file:line evidence; Agent Execution Plan has files + repo-sourced commands + verifiable acceptance criteria; Verification & Rollback Recipe concrete.
Once OQ-1/OQ-2/OQ-4 are resolved, flip §7 to
yesand (optional) hand torfc-reviewerfor a second-pass score.