RFC: Launchpad Teams
| Status | RFC |
|---|---|
| Type | Backend (Go service — qontak-launchpad) |
| Owner | Qontak Bifrost |
| Submitted Date | 6/11/2026 |
| Last Updated | 7/21/2026 |
| Repo | bitbucket.org/terbang-ventures/qontak-launchpad |
| Related Documents | https://jurnal.atlassian.net/wiki/spaces/QON/pages/49655742465 |
| Tracking Tickets | BIF-8603 / 8604 / 8605 / 8606 / 8607 / 8608 / 8609 (shipped) · BIF-8685 / 8789 / 8798 / 8799 / 8800 / 8801 / 8835 (shipped) · BIF-8864 / 8865 / 8866 (shipped) |
Sections at a Glance
| # | Section | What it answers |
|---|---|---|
| 1 | Overview & Context | What a Team is, current state, goals, scope |
| 2 | Infrastructure & Topology | Where this runs; pods, DB, cache, queue, consumers |
| 3 | Repo Reading Guide | Files an agent must read first (with anchors) |
| 4 | Data Model | teams (+ source tracking), team_users, team_upload_jobs |
| 5 | API Endpoints | All team endpoints (auth, request, response) |
| 6 | Domain Event Flows (Kafka) | Create/Update/Delete + delete-user flow (sequence diagrams) |
| 7 | Migration & Source-Tracking (as-built) | BIF-8607/8608/8609 + 8685/8789/8798/8799/8800/8801/8835/8864/8865/8866 |
| 8 | Architecture Decisions (ADR) | Async, cache key, collision strategy, authz |
| 9 | HA & Reliability | Delivery, ordering, non-blocking publish |
| 10 | Security | PII, authz |
| 11 | Rollout Plan | Ordered steps + owners |
| 12 | Delivery History | Shipped tickets & where they landed |
| 13 | Open Questions | Blockers tagged by severity |
| 14 | Ready for Agent Execution | §13 gate |
1. Overview & Context
This document describes the Teams feature in Qontak Launchpad — what it is, how it works, its API surface, the Kafka domain events it already publishes, and the migration & source-tracking features that let Qontak Chat and Qontak CRM onboard onto Launchpad's team model.
1.1 Current state (important)
✅ As-built status (2026-07-21). The entire Teams feature described here is implemented and merged: lifecycle CRUD + Kafka publishing (BIF-8603/8604/8605/8606), the bulk-migration features formerly tracked as in-progress (BIF-8607 migrate-aware create + Redis cache, BIF-8608 async bulk create, BIF-8609 status endpoint), and a later wave of source-tracking and pushed-migration work (BIF-8685 general team +
company_sso_idin payloads, BIF-8798 source-tracking columns, BIF-8799 pushed-migration receive path +TEAM_MIGRATED, BIF-8800 general-team source + async retry, BIF-8801 Heimdall migration triggers, BIF-8789 mandatory team on SSO invite, BIF-8835 teams in user responses). A later wave (2026-07-15/16) added the recursivechildrentree on the team attribute in user responses (BIF-8864), the private service-to-service team endpoints under/private/teams(BIF-8865), and full Kafka coverage of every team-membership action (BIF-8866). A subsequent addition (2026-07-21) extended the private surface with an async bulk-create counterpart under/private/teams/bulk(§5.2.8–5.2.9). This RFC documents the as-built behaviour and treats the code as the source of truth. The two[critical]open questions that previously blocked readiness are now resolved (see §13).
1.2 What is a Team in Launchpad?
A Team is a named grouping of users within a company. Teams are scoped to a single company and support a parent-child hierarchy (e.g. National → Regional → Branch). They are the foundational unit for data-permission scoping: when a user has view: team or manage: team on a feature, their visibility is bounded to the records owned by members of their team(s). Each team also records its origin via source_identifier (Launchpad/Chat/CRM) and an optional reference_id, which together give migrated teams a stable idempotency key (§4, BIF-8798).
1.3 Relationship with Users
Teams and Users have a many-to-many relationship via the team_users join table:
- A single user can belong to multiple teams simultaneously.
- A team can have any number of members.
- The link is through the user's internal Launchpad
user.id(notsso_id), so the join is always company-scoped.
| Table | Key Columns | Notes |
|---|---|---|
teams | id, company_id, name, parent_id, source_identifier, reference_id, created_at, updated_at | parent_id self-referencing (NULL = root); source_identifier ∈ Chat/CRM/Launchpad (default Launchpad); unique (company_id, source_identifier, reference_id) where reference_id not null |
team_users | id, team_id, user_id | FK to teams.id and users.id, both ON DELETE CASCADE; UNIQUE(user_id, team_id) — inserts use ON CONFLICT DO NOTHING (idempotent) |
users | id, sso_id, company_id | sso_id is the Mekari SSO identity |
1.4 Goals
- Document every team endpoint with request/response contracts.
- Document the as-built Kafka event contract so Chat & CRM can subscribe with confidence.
- Document the delete-user → team-member-update flow (previously undocumented).
- Document the as-built migration & source-tracking features (migrate-aware create, async bulk create, status polling, pushed-migration receive path, Heimdall triggers).
- Document the private (service-to-service) team endpoints (
/private/teams, BIF-8865) and the recursivechildrentree on the team attribute in user responses (BIF-8864).
1.5 Non-Goals / Out of Scope
- Consumer implementation in Qontak Chat / CRM (owned by those squads).
- Data-permission enforcement logic (covered in the parent RFC).
- Team-based reporting or analytics.
2. Infrastructure & Topology
Launchpad runs as a stateless Go service behind the Mekari API Gateway, plus a separate worker process (gocraft/work over Redis) for background jobs. Teams touch Postgres, Redis, Kafka, and the qontak-preferences feature-flag service.
flowchart TB
client["Chat / CRM / Web client"] --> gw["Mekari API Gateway
api.mekari.com (prod) / api.mekari.io (stg)"]
gw --> lb["Internal LB"]
lb --> srv["qontak-launchpad
HTTP server pods
(internal/server)"]
subgraph launchpad["qontak-launchpad"]
srv --> svc["TeamService / UserService
(internal/app/service)"]
wkr["worker pods
(gocraft/work)
internal/worker"] --> svc
end
svc -->|"read/write"| pgP[("Postgres PRIMARY")]
svc -->|"reads (list/detail)"| pgR[("Postgres REPLICA")]
svc -->|"team-list snapshot (BIF-8607)
bulk jobs queue (BIF-8608)"| redis[("Redis")]
svc -->|"flag check
launchpad_publish_team_update"| pref["qontak-preferences"]
svc -->|"Publish
bifrost.team.events.v1"| kafka{{"Kafka"}}
kafka --> chat["qontak-chat consumer"]
kafka --> crm["qontak-crm consumer"]
kafka -.future.-> cdp["cdp / kb"]
2.1 Per-service responsibility
| Component | Responsibility | Third-party / data deps |
|---|---|---|
internal/server | HTTP routing, auth/permission middleware, request binding | — |
TeamService (internal/app/service/teams) | Team CRUD, hierarchy, members, bulk create, Kafka publish, Chat/CRM migration triggers (Heimdall) | Postgres, Redis (team-list cache), Kafka, qontak-preferences, Chat/CRM APIs |
UserService (internal/app/service/users) | User CRUD incl. delete → strip team membership → publish TEAM_UPDATED | Postgres, Kafka (via TeamService), SSO/CRS/Chat/CRM |
worker (internal/worker) | Background jobs: MIGRATE_COMPANY_FULL, BULK_CREATE_TEAM (BIF-8608), CREATE_GENERAL_TEAM (BIF-8800 async retry) | Redis (gocraft/work), Postgres |
| Redis | SSO-auth cache (existing); per-company team-list snapshot (BIF-8607); job backend | — |
| Kafka | Single topic bifrost.team.events.v1, partitioned by team_id | — |
| qontak-preferences | Per-company feature-flag evaluation | — |
3. Repo Reading Guide
Files to read first when working on the teams feature. Anchors were verified on 2026-06-25 for the original CRUD/bulk work; the 2026-07 source-tracking wave (BIF-8798/8799/8800/8801/8835) added teams/notification.go, teams/cache.go, teams/bulk_create.go, consumer/bulk_create_team.go, consumer/create_general_team.go, and api/{chat,crm}/trigger_migration.go — re-verify line numbers before relying on them.
3.1 Existing Code Anchors
| File | Anchor | What to learn |
|---|---|---|
internal/app/service/teams/main.go | :32-38 | TeamService struct fields (repo, notificationService, kafkaProducer) + NewTeamService(repo, chatClient, crmClient, kafkaProducer) + ITeamService interface to extend |
internal/app/service/teams/events.go | :18-317 | As-built event structs + publish helpers — copy these payload shapes verbatim into §6 |
internal/app/service/teams/create.go | :15, :109 | Create flow; maybePublishTeamCreatedEvent called after tx commit |
internal/app/service/teams/update.go | :17, :170 | Update flow; publish on a detached 5s-timeout context after commit |
internal/app/service/teams/delete.go | :12, :42 | Delete flow; maybePublishTeamDeletedEvent (fully non-blocking) |
internal/app/service/teams/list.go | :16, :38 | ListTeamByCompany projection — reuse its shape for the BIF-8607 cache |
internal/app/service/users/delete.go | :20-31, :145-183 | delete-user → team-member flow: strip team_users in-tx, publish TEAM_UPDATED per affected team after commit |
internal/kafka/topics.go | :9, :15 | TopicCompanySettingsEventsV1 (template) and TopicTeamEventsV1 = "bifrost.team.events.v1" |
internal/pkg/constants/preferences.go | :16, :26 | FeaturePublishCompanySettingUpdate (template) and FeaturePublishTeamUpdate = "launchpad_publish_team_update" |
internal/app/service/companies/edit.go | :297-372 | maybePublishCompanySettingsUpdatedEvent — the publish-guard pattern team events follow |
internal/app/service/companies/migrate_full.go | :81-139 | EnqueueMigrateCompanyFull (enqueue at :111) — async-job template for BIF-8608 |
internal/app/consumer/migrate_company_full.go | :21-47 | MigrateCompanyFullConsumer — consumer template for BIF-8608 |
internal/app/queue/job_enqueuer.go | :14-16 | IJobEnqueuer.EnqueueJob(ctx, job_name, params) |
internal/pkg/consts/worker.go | :12 | MigrateCompanyFullJobName = "MIGRATE_COMPANY_FULL" — add BulkCreateTeamJobName beside it |
internal/worker/worker_service.go | :48, :63 | registerJob — where to register BULK_CREATE_TEAM |
db/query/team.sql | :1-146 | 17 existing queries; no ListAllTeamByCompany yet (add for BIF-8607) |
db/query/migration_jobs.sql | :1-27 | CRUD shape to mirror for team_upload_jobs (BIF-8608) |
internal/server/rest_router.go | :117-126 | /iag/v1/teams route group — add /bulk + /bulk/{upload_id} here |
cmd/initializer.go | :96-98 | NewTeamService(repo, chatClient, crmClient, producer) wiring; pass jobEnqueuer/cacheRepo for §7 |
internal/app/service/teams/create_private.go … delete_private.go | — | Private (S2S) endpoint variants (BIF-8865); resolve company via company_sso_id, reuse createSingleTeam, emit the same events with actor uuid.Nil |
internal/app/service/teams/validate_team_access.go | — | validateTeamBelongsToCompany — enforces team.CompanyID == company.ID on private {id} ops (else 403) |
internal/app/service/users/team_helpers.go | — | buildTeamInfoWithChildren/buildChildrenTree — recursive children tree for user responses (BIF-8864); DFS path-set guards circular parent_id |
internal/app/service/teams/cache.go | — | teams:hierarchy:{company_sso_id} cache (24h) added for BIF-8864; invalidated on every create/update/delete alongside the team-list cache |
3.2 Reading order for the agent
internal/app/service/teams/main.gointernal/app/service/teams/events.gointernal/app/service/teams/create.gointernal/app/service/users/delete.gointernal/kafka/topics.gointernal/app/service/companies/migrate_full.gointernal/app/consumer/migrate_company_full.godb/query/team.sql+db/query/migration_jobs.sqlinternal/server/rest_router.gocmd/initializer.go
3.3 Source Verification
| Claim in this RFC | Evidence |
|---|---|
Topic = bifrost.team.events.v1 | internal/kafka/topics.go:15 |
Flag = launchpad_publish_team_update | internal/pkg/constants/preferences.go:26 |
| Publish is after-commit & non-blocking | create.go:109, update.go:170, delete.go:42, events.go:230-290 |
TEAM_CREATED payload includes company_sso_id | events.go:24-30 (TeamCreatedPayload) |
TEAM_UPDATED payload includes company_sso_id | events.go:44-52 (TeamEventPayload) |
TEAM_DELETED payload has company_id + company_sso_id | events.go:67-72 (TeamDeletedPayload) |
TEAM_MIGRATED event exists (migrate mode) | events.go:85-182 (TeamMigratedPayload) |
Delete-user strips team_users then publishes per team | users/delete.go:145, :169-183, events.go:296-317 |
ListAllTeamByCompany exists (BIF-8607) | db/query/team.sql, repository/team.sql.go |
team_upload_jobs table exists (BIF-8608) | migration 20260625130000_create_team_upload_jobs_table; repository/team_upload_jobs.sql.go |
Routes live under /iag/v1/teams | internal/server/rest_router.go:117-126 |
Handlers read caller from X-Authenticated-Userid | team_handler.go:64,91,131,167,219,248 |
Response envelope = { "data": ... } | internal/pkg/http/handler.go:25-36 |
Private team routes live under /private/teams (Basic auth) | internal/server/rest_router.go:163-171 |
User-response team attribute now has a recursive children field | internal/pkg/response/user_response.go:11-15 (TeamInfo.Children) |
Member-add flows publish TEAM_UPDATED (BIF-8866) | users/sso_invite.go, users/update.go (PublishTeamMembersUpdated) |
4. Data Model
erDiagram
teams ||--o{ team_users : has
users ||--o{ team_users : "belongs to"
teams ||--o{ teams : "parent_id"
teams {
uuid id PK
uuid company_id
varchar name
uuid parent_id "nullable; NULL = root"
text source_identifier "Chat|CRM|Launchpad; default Launchpad"
text reference_id "nullable; source app team id"
timestamptz created_at
timestamptz updated_at
}
team_users {
uuid id PK
uuid user_id FK "ON DELETE CASCADE"
uuid team_id FK "ON DELETE CASCADE"
timestamptz created_at
timestamptz updated_at
}
4.1 team_upload_jobs (BIF-8608, as-built)
Backs the async bulk-create flow (§5.1.8 / §7.2). It has no company_id — a bulk batch spans many companies; per-item company_sso_id lives inside result (ADR-6). Created by migration 20260625130000_create_team_upload_jobs_table.
CREATE TABLE team_upload_jobs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
job_id VARCHAR(255),
created_by_sso_id UUID, -- caller, for audit + creator-scoped read (BIF-8609)
status VARCHAR(50) NOT NULL DEFAULT 'pending',
total INT NOT NULL DEFAULT 0,
processed INT NOT NULL DEFAULT 0,
result JSONB, -- per-item {company_sso_id, name, team_id, status, error}
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_team_upload_jobs_status ON team_upload_jobs (status);
5. API Endpoints
5.0 Common conventions
-
Two endpoint surfaces. Teams expose two surfaces with different auth models: the SSO surface under
/iag/v1/teams(§5.1) — user Mekari SSO token, caller's company resolved from the session — and the private service-to-service surface under/private/teams(§5.2, BIF-8865) — HTTP Basic auth, company supplied explicitly ascompany_sso_id(no user session). The two surfaces share the same service core and emit the same Kafka events. -
Base endpoint. All team endpoints are exposed through the Mekari API Gateway at
{{mekari_api_host}}/internal/qontak/launchpad/v1/teams({{mekari_api_host}}=api.mekari.comin production,api.mekari.ioin staging). The gateway maps this prefix to Launchpad's internal route group/iag/v1/teams(internal/server/rest_router.go:117-126). Paths below are shown relative to that base — e.g. "/{id}" ={{mekari_api_host}}/internal/qontak/launchpad/v1/teams/{id}. -
Authorization (Mekari SSO token). Every team endpoint requires a user Mekari SSO token:
Authorization: Bearer <mekari_sso_token>The gateway/SSO middleware validates the token and injects the caller's SSO identity as the
X-Authenticated-Useridheader (the user'ssso_id). Every handler readsreq.UserSSOID = r.Header.Get("X-Authenticated-Userid")(team_handler.go:64,91,131,167,219,248) — the service resolves the caller's company from this id and scopes all team operations to it. Requests are additionally gated byPermissionCheckmiddleware. -
Response envelope. Successful responses are wrapped by
WriteJSON(internal/pkg/http/handler.go:25-36) as:{ "data": <payload> }List endpoints place their
paginationobject insidedata(the payload is itself{ "pagination": {...}, "data": [...] }). All examples below show the payload; assume the outer{ "data": ... }wrapper unless noted. -
Error shape. Failures return the standard error body with an HTTP status of
400/404/500;400carries a human-readabledescription.
5.1 Endpoints (SSO surface — /iag/v1/teams)
| # | Method | Path (relative to base) | Handler | Auth |
|---|---|---|---|---|
| 5.1.1 | POST | / | CreateTeam | SSO Bearer |
| 5.1.2 | PATCH | /{id} | UpdateTeam | SSO Bearer |
| 5.1.3 | DELETE | /{id}/delete | DeleteTeam | SSO Bearer |
| 5.1.4 | GET | / | ListTeam | SSO Bearer |
| 5.1.5 | GET | /hierarchy | ListTeamHierarchy | SSO Bearer |
| 5.1.6 | GET | /{id} | DetailTeam | SSO Bearer |
| 5.1.7 | GET | /{id}/members | ListMembers | SSO Bearer |
| 5.1.8 | POST | /bulk | BulkCreateTeam | SSO Bearer |
| 5.1.9 | GET | /bulk/{upload_id} | GetBulkCreateTeamStatus | SSO Bearer |
Routing note: the static
/bulk/...segment takes precedence overGET /{id}so a bulk request does not resolve intoDetailTeam(chi resolves static segments first).
5.1.1 Create Team — POST /
Creates a team under the caller's company and publishes TEAM_CREATED — or TEAM_MIGRATED when is_migrate=true (§6.3). Request struct request.CreateTeam (team_request.go:10-52).
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | ✅ | team name (company-scoped unique) |
member_ids | string[] | conditional | member SSO ids; required (≥1) in normal mode, optional when is_migrate=true |
parent_id | string | ❌ | parent team id; omit/empty = root team |
is_migrate | bool | ❌ (default false) | when true, a name collision is auto-disambiguated instead of failing |
app | string | conditional | allowed ""/chat/crm; required when is_migrate=true |
app_identifier_id | string | conditional | source app's team id (≤255 chars); required when is_migrate=true; stored as reference_id for idempotency (BIF-8799) |
{ "name": "Sales North", "member_ids": ["sso-uuid-1", "sso-uuid-2"], "parent_id": "parent-team-uuid" }
Migration example (collision auto-disambiguation):
{ "name": "Sales", "member_ids": ["sso-uuid-1"], "parent_id": "", "is_migrate": true, "app": "crm" }
Validation (CreateTeam.Validate): name is required and ≤251 chars (4 reserved for the app prefix); in normal mode member_ids must be non-empty; in migrate mode app ∈ chat/crm and app_identifier_id (≤255) are required. Failures return 400 with a human-readable description.
Migration behaviour: if name is already taken and is_migrate && app != "", the create is retried once as "{app}-{name}" (e.g. crm-Sales); if the prefixed name is also taken → 400 "error: name has been taken". The response carries the effective (possibly prefixed) name.
Response 200 — response.CreateTeamResponse (team_response.go:5-10)
{ "data": { "id": "new-team-uuid", "name": "Sales North", "parent_id": "parent-team-uuid", "member_ids": ["sso-uuid-1", "sso-uuid-2"] } }
Errors: 400 (missing fields, name taken in company, parent not found, ancestry depth exceeded), 404 (calling user not found), 500.
5.1.2 Update Team — PATCH /{id}
Replaces the team's attributes and full member list (not a diff); parent_id may be changed to re-parent. Publishes TEAM_UPDATED with a computed update_mask (§6.3). Request struct request.UpdateTeam (team_request.go:29-43); id from path, caller from header.
Path param: id — team id.
Request body (same shape as create; member_ids replaces membership wholesale)
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | ✅ | new name |
member_ids | string[] | ✅ (≥1) | full member SSO-id list (replace) |
parent_id | string | ❌ | new parent; empty = root |
{ "name": "Sales North Updated", "member_ids": ["sso-uuid-1", "sso-uuid-3"], "parent_id": "parent-team-uuid" }
Validation (UpdateTeam.Validate): non-empty member_ids and name, else 400 "Failed to update team. Please ensure all required fields are filled."
Response 200 — response.UpdateTeamResponse (team_response.go:12-17)
{ "data": { "id": "team-uuid", "name": "Sales North Updated", "parent_id": "parent-team-uuid", "member_ids": ["sso-uuid-1", "sso-uuid-3"] } }
Errors: 400, 404 (team/user not found), 500.
5.1.3 Delete Team — DELETE /{id}/delete
Deletes the team and publishes TEAM_DELETED (§6.3). No request body; id from path, caller from header (request.DeleteTeam, team_request.go:24-27).
Path param: id — team id.
Response 200 — string payload
{ "data": "successfully delete team" }
Errors: 400 (e.g. team has children), 404, 500.
5.1.4 List Teams — GET /
Paginated, company-scoped list. Request struct request.ListTeam (team_request.go:45-51, embeds http.PaginationRequest).
Query params
| Param | Type | Default | Allowed |
|---|---|---|---|
page | int | 1 | — |
per_page | int | (default/limit-adjusted) | — |
order_by | string | name | name, members, updated_at |
order_direction | string | asc | asc, desc |
query | string | — | fuzzy match on team name |
Response 200 — response.ListTeamResponse (team_response.go:19-31); note pagination is nested inside data.
{
"data": {
"pagination": { "page": 1, "per_page": 20, "total": 42, "total_pages": 3 },
"data": [
{ "id": "team-uuid", "name": "Sales North", "parent_id": "parent-uuid", "parent_name": "Sales", "members": 5, "updated_at": "2026-06-11T07:00:00Z" }
]
}
}
Errors: 400 (invalid order_by/order_direction), 500.
5.1.5 Team Hierarchy — GET /hierarchy
Returns all teams for the caller's company as a nested tree. No params beyond the auth header.
Response 200 — []response.TeamHierarchyResponse (team_response.go:33-39)
{
"data": [
{
"id": "root-team-uuid", "name": "Sales", "parent_id": "", "members": 2,
"children": [
{ "id": "child-team-uuid", "name": "Sales North", "parent_id": "root-team-uuid", "members": 5 }
]
}
]
}
children is omitted when empty (omitempty).
5.1.6 Team Detail — GET /{id}
Single team's core attributes (request.DetailTeam).
Path param: id — team id.
Response 200 — response.DetailTeamResponse (team_response.go:48-52)
{ "data": { "id": "team-uuid", "name": "Sales North", "parent_id": "parent-uuid" } }
Errors: 404 (team not found / not in caller's company), 500.
5.1.7 List Team Members — GET /{id}/members
Members of the team with their roles (request.ListMembers).
Path param: id — team id.
Response 200 — []response.ListMemberResponse (team_response.go:41-46)
{ "data": [ { "id": "user-launchpad-uuid", "sso_id": "user-sso-uuid", "name": "John Doe", "roles": ["Admin"] } ] }
5.1.8 Bulk Create Teams — POST /bulk
Async, multi-company bulk create. Validates, persists a team_upload_jobs record, enqueues a BULK_CREATE_TEAM worker job, and returns 202 Accepted immediately with an upload_id. Auth = SSO Bearer; created_by_sso_id is taken from X-Authenticated-Userid for audit (the caller is not required to belong to the items' companies — see ADR-6).
Request body — request.BulkCreateTeam ({ teams: [ CreateTeamItem... ] })
Each item = the create body plus a required company_sso_id:
| Field | Type | Required | Notes |
|---|---|---|---|
company_sso_id | string (UUID) | ✅ | company the team is created under; resolved via GetCompanyBySsoId |
name | string | ✅ | team name |
member_ids | string[] | ✅ (≥1) | member SSO ids (must belong to the item's company) |
parent_id | string | ❌ | parent team id within the same company |
is_migrate | bool | ❌ | BIF-8607 collision handling |
app | string | conditional | chat/crm; required when is_migrate=true |
app_identifier_id | string | conditional | source app's team id; required when is_migrate=true |
{
"teams": [
{ "company_sso_id": "company-a-sso", "name": "Sales", "member_ids": ["sso-1"], "is_migrate": true, "app": "crm" },
{ "company_sso_id": "company-b-sso", "name": "Support", "member_ids": ["sso-2"], "parent_id": "parent-uuid" }
]
}
Validation: teams non-empty and ≤ 500 items (maxBulkTeamBatchSize, team_request.go:119; overflow → 400), every item has a parseable UUID company_sso_id + the §5.1.1 name/member rules + valid app/app_identifier_id.
Response 202 — response.BulkCreateTeamResponse
{ "data": { "upload_id": "upload-uuid", "status": "pending", "total": 2 } }
Errors: 400 (empty/oversized batch, bad item / non-UUID company_sso_id), 500 (enqueue failure → job row marked failed).
5.1.9 Bulk Create Status — GET /bulk/{upload_id}
Polls the status of a bulk job. Auth = SSO Bearer; creator-scoped — the record is returned only if team_upload_jobs.created_by_sso_id == X-Authenticated-Userid, otherwise 404 (ADR-6 / OQ-3).
Path param: upload_id — UUID from the 202 response.
Response 200 — response.TeamUploadJobStatusResponse. status ∈ pending / processing / completed / completed_with_errors / failed; result is omitted while pending/processing and otherwise lists per-item outcomes (each carrying its own company_sso_id).
{
"data": {
"id": "upload-uuid",
"status": "completed_with_errors",
"total": 2,
"processed": 2,
"result": [
{ "company_sso_id": "company-a-sso", "name": "crm-Sales", "team_id": "team-a-uuid", "status": "created", "error": "" },
{ "company_sso_id": "company-b-sso", "name": "Support", "team_id": "", "status": "failed", "error": "company not found" }
],
"created_at": "2026-06-25T07:00:00Z",
"updated_at": "2026-06-25T07:00:05Z"
}
}
Errors: 400 (non-UUID upload_id), 404 (unknown id or caller ≠ creator), 500.
5.2 Private endpoints (Basic auth, service-to-service) — /private/teams
Added in BIF-8865 for callers that have no end-user SSO session (e.g. Chat/CRM back-ends). Registered under the /private group guarded by HTTP Basic auth (internal/server/rest_router.go:163-171). Conventions:
- Company scoping. There is no session, so the company is supplied explicitly as
company_sso_id— a query param on GET/DELETE and a body field on POST/PATCH. Every requestValidate()s thatcompany_sso_idis present and a valid UUID (else400). The company is resolved viaGetCompanyBySsoId(unknown →404). - Tenant isolation.
{id}operations verify the team belongs to the given company viavalidateTeamBelongsToCompany; a mismatch returns403. - Actor. Create/delete record the actor as
uuid.Nil(no user identity), so the emitted event's*_by_sso_idis the nil UUID. - Same core, same events. These reuse the same service core as §5.1 and emit the same Kafka events (create →
TEAM_CREATED/TEAM_MIGRATED, update →TEAM_UPDATED, delete →TEAM_DELETED). Responses reuse the §5.1 response structs. The private surface now includes an async/bulkcounterpart (§5.2.8–5.2.9); there is still no menu-visibility endpoint.
| # | Method | Path | Handler | company_sso_id in | Response |
|---|---|---|---|---|---|
| 5.2.1 | POST | /private/teams | CreateTeamPrivate | body | CreateTeamResponse |
| 5.2.2 | GET | /private/teams | ListTeamPrivate | query | ListTeamResponse |
| 5.2.3 | GET | /private/teams/hierarchy | ListTeamHierarchyPrivate | query | []TeamHierarchyResponse |
| 5.2.4 | GET | /private/teams/{id}/members | ListMembersPrivate | query | []ListMemberResponse |
| 5.2.5 | GET | /private/teams/{id} | DetailTeamPrivate | query | DetailTeamResponse |
| 5.2.6 | PATCH | /private/teams/{id} | UpdateTeamPrivate | body | UpdateTeamResponse |
| 5.2.7 | DELETE | /private/teams/{id}/delete | DeleteTeamPrivate | query | string |
| 5.2.8 | POST | /private/teams/bulk | BulkCreateTeamPrivate | body (per-item) | BulkCreateTeamResponse |
| 5.2.9 | GET | /private/teams/bulk/{upload_id} | GetBulkCreateTeamStatusPrivate | — | TeamUploadJobStatusResponse |
Routing note: the static
/bulkand/bulk/{upload_id}routes are registered before/{id}(internal/server/rest_router.go) so a bulk request resolves to the bulk handlers, notDetailTeamPrivate. Unlike §5.2.1–5.2.7, the bulk endpoints are cross-company by design (each item carries its owncompany_sso_id) — they take no top-levelcompany_sso_idand perform no403tenant-isolation check; per-item company resolution and validation are identical to §5.1.8.
5.2.1 Create — POST /private/teams
Same fields as §5.1.1 CreateTeam plus a required company_sso_id; in migrate mode member_ids is optional (same rules as §5.1.1). Emits TEAM_CREATED / TEAM_MIGRATED (actor uuid.Nil).
// request body
{ "company_sso_id": "company-sso-uuid", "name": "Sales North",
"member_ids": ["sso-uuid-1", "sso-uuid-2"], "parent_id": "parent-team-uuid",
"is_migrate": false, "app": "", "app_identifier_id": "" }
// 200
{ "data": { "id": "new-team-uuid", "name": "Sales North",
"parent_id": "parent-team-uuid", "member_ids": ["sso-uuid-1", "sso-uuid-2"] } }
5.2.2 List — GET /private/teams
Query: company_sso_id (required), plus page, per_page, order_by (name/members/updated_at), order_direction (asc/desc), query. pagination is nested inside data.
{ "data": { "pagination": { "page": 1, "per_page": 20, "total": 42, "total_pages": 3 },
"data": [ { "id": "team-uuid", "name": "Sales North", "parent_id": "parent-uuid",
"parent_name": "Sales", "members": 5, "updated_at": "2026-07-16T07:00:00Z" } ] } }
5.2.3 Hierarchy — GET /private/teams/hierarchy?company_sso_id=...
Same nested-tree shape as §5.1.5 (children omitted when empty).
{ "data": [ { "id": "root-uuid", "name": "Sales", "parent_id": "", "members": 2,
"children": [ { "id": "child-uuid", "name": "Sales North", "parent_id": "root-uuid", "members": 5 } ] } ] }
5.2.4 Members — GET /private/teams/{id}/members?company_sso_id=...
{ "data": [ { "id": "user-uuid", "sso_id": "user-sso-uuid", "name": "John Doe", "roles": ["Admin"] } ] }
5.2.5 Detail — GET /private/teams/{id}?company_sso_id=...
{ "data": { "id": "team-uuid", "name": "Sales North", "parent_id": "parent-uuid" } }
5.2.6 Update — PATCH /private/teams/{id}
Body requires company_sso_id, non-empty name and member_ids; member_ids replaces membership wholesale. Emits TEAM_UPDATED with a computed update_mask.
// request body
{ "company_sso_id": "company-sso-uuid", "name": "Sales North Updated",
"member_ids": ["sso-uuid-1", "sso-uuid-3"], "parent_id": "parent-team-uuid" }
// 200
{ "data": { "id": "team-uuid", "name": "Sales North Updated",
"parent_id": "parent-team-uuid", "member_ids": ["sso-uuid-1", "sso-uuid-3"] } }
5.2.7 Delete — DELETE /private/teams/{id}/delete?company_sso_id=...
Fails if the team has children. Emits TEAM_DELETED (actor uuid.Nil).
{ "data": "successfully delete team" }
Errors (§5.2.1–5.2.7): 400 (missing/invalid company_sso_id or other validation failures), 403 (team not in the given company), 404 (team/company not found), 500.
5.2.8 Bulk Create Teams — POST /private/teams/bulk
Service-to-service, Basic-auth counterpart of §5.1.8. Same multi-company async model and the same request.BulkCreateTeam body; validates, persists a team_upload_jobs row, enqueues BULK_CREATE_TEAM, and returns 202 with an upload_id. There is no user session, so the job's created_by_sso_id is recorded as the system actor uuid.Nil (EnqueueBulkCreateTeam(ctx, req, uuid.Nil.String())) — there is no X-Authenticated-Userid.
Request body — { teams: [ item... ] }, each item:
| Field | Type | Required | Notes |
|---|---|---|---|
company_sso_id | string (UUID) | ✅ | company the team is created under; resolved via GetCompanyBySsoId |
name | string | ✅ | team name |
member_ids | string[] | ✅ (≥1) | member SSO ids (must belong to the item's company) |
parent_id | string | ❌ | parent team id within the same company |
is_migrate | bool | ❌ | collision auto-disambiguation |
app | string | conditional | chat/crm; required when is_migrate=true |
app_identifier_id | string | conditional | source app's team id; required when is_migrate=true |
// request
{ "teams": [
{ "company_sso_id": "company-a-sso", "name": "Sales", "member_ids": ["sso-1"], "is_migrate": true, "app": "crm" },
{ "company_sso_id": "company-b-sso", "name": "Support", "member_ids": ["sso-2"], "parent_id": "parent-uuid" }
] }
// 202
{ "data": { "upload_id": "upload-uuid", "status": "pending", "total": 2 } }
Validation (req.Validate(), shared with §5.1.8): teams non-empty and ≤ 500 items; every item a parseable UUID company_sso_id + the §5.1.1 name/member rules + valid app/app_identifier_id.
Errors: 400 (empty/oversized batch, bad item / non-UUID company_sso_id), 500.
5.2.9 Bulk Create Status — GET /private/teams/bulk/{upload_id}
Basic-auth counterpart of §5.1.9. System-actor-scoped, not SSO-creator-scoped: the record is returned only if team_upload_jobs.created_by_sso_id == uuid.Nil (GetTeamUploadJobForCaller(ctx, uploadID, uuid.Nil.String())), so it surfaces only jobs created through the private path; an unknown or non-private upload_id returns 404 (no existence oracle).
Path param: upload_id — UUID from the 202 response (non-UUID → 400).
Response 200 — response.TeamUploadJobStatusResponse (identical shape to §5.1.9); status ∈ pending / processing / completed / completed_with_errors / failed; result is omitted while pending/processing.
{
"data": {
"id": "upload-uuid",
"status": "completed_with_errors",
"total": 2,
"processed": 2,
"result": [
{ "company_sso_id": "company-a-sso", "name": "crm-Sales", "team_id": "team-a-uuid", "status": "created", "error": "" },
{ "company_sso_id": "company-b-sso", "name": "Support", "team_id": "", "status": "failed", "error": "company not found" }
]
}
}
Errors: 400 (non-UUID upload_id), 404 (unknown id or job not created via the private path), 500.
6. Domain Event Flows (Kafka) — As Built
6.1 Design principles
- Follows the same pattern as
bifrost.company.settings.events.v1(internal/app/service/companies/edit.go:297). - Launchpad is the single publisher; Chat, CRM and future consumers subscribe independently.
- Publishing is gated per-company by feature flag
launchpad_publish_team_update(constants/preferences.go:26). - Publish happens after the DB transaction commits and is non-blocking — a Kafka failure never rolls back the mutation.
- Every team-membership mutation emits an event (BIF-8866). Beyond the direct team CRUD, all user-side membership changes now publish
TEAM_UPDATED(update_mask=["members"]) viaPublishTeamMembersUpdated— member adds on SSO invite / existing-SSO-user provisioning / user update (users/sso_invite.go,users/update.go) and member removes on user delete / delete-by-email (users/delete.go,users/private_delete_by_email.go). - Same events from the private surface. The
/private/teamsendpoints (§5.2) publish the same four event types; create/delete record the actor (*_by_sso_id) as the nil UUID because there is no user session.
6.2 Topic
| Property | Value |
|---|---|
| Topic name | bifrost.team.events.v1 (internal/kafka/topics.go:15) |
| Partition key | team_id (ordering per team) |
| Producer | qontak-launchpad |
| Consumers | qontak-chat, qontak-crm (future: cdp, kb) |
6.3 Event envelope & payloads (as-built, events.go)
All events share the envelope { event_id, event_type, aggregate_id (=team_id), aggregate_type="TEAM", version="1.0", occurred_at (RFC3339 UTC), payload }. There are four event types: TEAM_CREATED, TEAM_MIGRATED, TEAM_UPDATED, TEAM_DELETED. The 2026-07 wave (BIF-8864/8865/8866) added no new event type and did not change these payload schemas — it only widened when and from where they are emitted (member-add flows and the private surface). Consumers should not expect a new schema. Migrate-mode create still emits TEAM_MIGRATED — including from the private CreateTeamPrivate path, which shares createSingleTeam (create.go:194-199).
✅ Resolved (was OQ-1). Every payload now carries
company_sso_id(events.go:29,51,92;TEAM_DELETEDalso hascompany_id), so consumers can scope events to a company from the payload alone. Publishing is skipped whencompany_sso_idis blank (uuid.Nil).
TEAM_CREATED — events.go:23-41
{
"event_id": "uuid", "event_type": "TEAM_CREATED",
"aggregate_id": "team-uuid", "aggregate_type": "TEAM",
"version": "1.0", "occurred_at": "2026-06-11T07:32:00Z",
"payload": {
"team_name": "Sales North",
"parent_id": "parent-team-uuid",
"members": [ { "user_id": "user-uuid-1", "sso_id": "sso-uuid-1" } ],
"created_by_sso_id": "actor-sso-uuid",
"company_sso_id": "company-sso-uuid"
}
}
TEAM_MIGRATED — events.go:85-106
Emitted instead of TEAM_CREATED when a team is created in migrate mode (is_migrate=true); extends the created payload with the originating app and its app_identifier_id (create.go:194-197).
{
"event_id": "uuid", "event_type": "TEAM_MIGRATED",
"aggregate_id": "team-uuid", "aggregate_type": "TEAM",
"version": "1.0", "occurred_at": "2026-07-08T07:32:00Z",
"payload": {
"team_name": "crm-Sales",
"parent_id": "parent-team-uuid",
"members": [ { "user_id": "user-uuid-1", "sso_id": "sso-uuid-1" } ],
"created_by_sso_id": "actor-sso-uuid",
"company_sso_id": "company-sso-uuid",
"app": "crm",
"app_identifier_id": "crm-team-123"
}
}
TEAM_UPDATED — events.go:42-61
update_mask lists only the fields that changed; the event is skipped entirely when update_mask is empty (events.go:162).
{
"event_id": "uuid", "event_type": "TEAM_UPDATED",
"aggregate_id": "team-uuid", "aggregate_type": "TEAM",
"version": "1.0", "occurred_at": "2026-06-11T08:10:00Z",
"payload": {
"team_id": "team-uuid",
"name": "Sales North Revised",
"parent_id": "parent-team-uuid",
"members": [ { "user_id": "user-uuid-1", "sso_id": "sso-uuid-1" } ],
"update_mask": ["name", "members"],
"updated_by_sso_id": "actor-sso-uuid",
"company_sso_id": "company-sso-uuid"
}
}
TEAM_DELETED — events.go:63-81
Member list omitted (team no longer exists); consumers clean up references.
{
"event_id": "uuid", "event_type": "TEAM_DELETED",
"aggregate_id": "team-uuid", "aggregate_type": "TEAM",
"version": "1.0", "occurred_at": "2026-06-11T09:00:00Z",
"payload": {
"team_id": "team-uuid", "company_id": "company-uuid",
"company_sso_id": "company-sso-uuid", "deleted_by_sso_id": "actor-sso-uuid"
}
}
6.4 Sequence — Create / Update / Delete team
sequenceDiagram
participant C as Client
participant GW as API Gateway
participant H as TeamHandler
participant S as TeamService
participant DB as Postgres (primary)
participant PF as qontak-preferences
participant K as Kafka (bifrost.team.events.v1)
C->>GW: POST/PATCH/DELETE /teams...
GW->>H: routed (SsoAuth + PermissionCheck)
H->>S: CreateTeam / UpdateTeam / DeleteTeam
S->>DB: BEGIN tx -> mutate teams/team_users
DB-->>S: COMMIT ok
Note over S: publish only AFTER commit
S->>PF: IsEnabled(launchpad_publish_team_update, company_sso_id)
alt flag enabled & company_sso_id present
S->>K: Publish(key=team_id, TEAM_CREATED/MIGRATED/UPDATED/DELETED)
Note over S,K: migrate mode -> TEAM_MIGRATED; else TEAM_CREATED. Failure logged & swallowed (non-blocking)
else disabled / blank sso_id / producer nil
Note over S: skip publish silently
end
S-->>H: domain result
H-->>C: 200 / "deleted successfully"
6.5 Sequence — User flows update team membership (BIF-8606 / 8866)
When a user is deleted, their team_users rows are removed inside the deletion transaction, and after commit a TEAM_UPDATED (update_mask=["members"]) is published per affected team via PublishTeamMembersUpdated (users/delete.go:145,169-183 → events.go:296-317). The sequence below shows the remove path.
Add path (BIF-8866). The symmetric member-add paths were previously silent; they now publish the same per-team TEAM_UPDATED after their work succeeds: SSO invite and existing-SSO-user provisioning (users/sso_invite.go, for each newly-assigned team) and user update with TeamIDs (users/update.go, after wg.Wait()). All calls are guarded by if s.teamPublisher != nil and are non-blocking. User update also enforces a per-user cap of 50 teams and an IDOR check that each team belongs to the target user's company (§10).
sequenceDiagram
participant C as Client
participant US as UserService.Delete
participant DB as Postgres (primary)
participant TS as TeamService
participant K as Kafka
C->>US: DELETE user (sso_id, actor, target)
US->>DB: BEGIN tx
US->>DB: ListTeamsByUser(userID) %% capture affected teams
US->>DB: DeleteTeamUsersByUser(userID) %% strip membership
US->>DB: UserDelete(userID) + audit log
DB-->>US: COMMIT ok
loop for each affected team
US->>TS: PublishTeamMembersUpdated(teamID, company_sso_id, actor_sso_id)
TS->>DB: FindTeamByID + ListTeamMember (remaining members)
TS->>K: TEAM_UPDATED (update_mask=["members"])
Note over US,K: per-team publish errors logged & skipped (non-blocking)
end
US-->>C: 200 (external SSO/CRS/Chat/CRM cleanup follows, best-effort)
6.6 Feature flag
| Property | Value |
|---|---|
| Flag | launchpad_publish_team_update |
| Service | qontak-preferences |
| Unique ID | company_sso_id (per-company rollout) |
| When disabled | skip publish silently (non-blocking) |
7. Migration & Source-Tracking (As-Built)
Chat and CRM migrate onto Launchpad's team model. §7.1–7.3 (BIF-8607→8608→8609) are the migrate-aware create, async bulk create, and status endpoint — all shipped. §7.4–7.7 cover the later source-tracking wave (BIF-8798/8799/8685/8800/8801/8789/8835), §7.8–7.10 the 2026-07 wave (BIF-8864 children tree, BIF-8865 private endpoints, BIF-8866 full Kafka coverage), and §7.11 the private bulk-create counterpart.
7.1 BIF-8607 — Migrate-aware create + Redis team-list cache (shipped)
- Add optional
is_migrate(bool) andapp(chat|crm) torequest.CreateTeam. When a name collides andis_migrate && app != "", retry with"{app}-{name}"; if that is also taken, returnerror: name has been taken. - Add a write-through Redis snapshot of the company team list, keyed
teams:company:{company_sso_id}, TTL24h, rebuilt from DB after every create/update/delete. - Requires a new non-paginated query
ListAllTeamByCompany(same projection asListTeamByCompany, no LIMIT/OFFSET/name filter) and injectingrepository.ICacheRepositoryintoTeamService.
7.2 BIF-8608 — Async bulk create (multi-company, worker-processed) (shipped)
POST /iag/v1/teams/bulkaccepts{ "teams": [ <item>, ... ] }; each item carries its owncompany_sso_idplus 8607'sis_migrate/app. The batch spans companies; no single caller-company is derived.- Handler validates → inserts a
team_upload_jobsrow (status=pending,total=N,created_by_sso_id=caller) → enqueuesBULK_CREATE_TEAM(gocraft/work) → returns 202 +upload_id. - Worker (
ProcessBulkCreateTeam) resolves each item's company viaGetCompanyBySsoId, calls the extracted single-create core (createSingleTeam), records per-item{company_sso_id, name, team_id, status, error}, bumpsprocessed, and refreshes the 8607 cache once per affected company. Per-item failures never abort the batch; final status iscompleted/completed_with_errors/failed.
sequenceDiagram
participant C as Migration client
participant H as BulkCreateTeam handler
participant S as TeamService
participant DB as Postgres
participant Q as Redis (gocraft/work)
participant W as Worker
participant K as Kafka
C->>H: POST /teams/bulk { teams:[multi-company] }
H->>S: EnqueueBulkCreateTeam(req)
S->>DB: INSERT team_upload_jobs (pending, total=N, created_by_sso_id)
S->>Q: EnqueueJob(BULK_CREATE_TEAM, {upload_id, items})
S->>DB: UpdateTeamUploadJobJobID
S-->>C: 202 { upload_id, status:pending, total:N }
Q->>W: BulkCreateTeamConsumer
W->>DB: status=processing
loop each item
W->>DB: GetCompanyBySsoId(item.company_sso_id)
W->>DB: createSingleTeam(company.ID, item) %% reuses 8607 core
W->>K: TEAM_CREATED / TEAM_MIGRATED (per created team)
W->>DB: record per-item result, processed++
end
W->>DB: status=completed / completed_with_errors / failed + result JSONB
W->>Redis: refresh team-list cache once per affected company
7.3 BIF-8609 — Bulk status endpoint (shipped)
GET /iag/v1/teams/bulk/{upload_id}returns{ id, status, total, processed, result, created_at, updated_at }, mirroringGET /private/companies/migrate/full/{job_id}(CompanyHandler.GetMigrationJobStatus).- Authorization is creator-scoped, not company-scoped: return the record only if
team_upload_jobs.created_by_sso_id == caller (X-Authenticated-Userid); otherwise 404 (avoid an existence oracle). Because the batch is cross-company by design, the original "upload belongs to caller's company" check does not apply — see ADR-6 and OQ-3.
7.4 BIF-8798 / 8799 — Source tracking & pushed-migration receive path (shipped)
- Columns.
teams.source_identifier(Chat/CRM/Launchpad, CHECK-constrained, defaultLaunchpad) + nullablereference_id, with a partial unique index(company_id, source_identifier, reference_id)wherereference_idis not null (migration20260707120000_add_source_tracking_to_teams). - Idempotency. In migrate mode with an
app_identifier_id, create first callsFindTeamBySourceRef(company_id, source_identifier, reference_id); if a row exists it is returned as-is (no re-insert, no event) — safe for retried pushes (create.go:110-129). - Source derivation (
buildSourceFields,create.go:257-267): migrate →source_identifier= title-casedapp(chat→Chat),reference_id=app_identifier_id; otherwiseLaunchpad/ null. - Event. Migrate-mode create emits
TEAM_MIGRATED(notTEAM_CREATED) — see §6.3.
7.5 BIF-8685 / 8800 — General team auto-provision + async retry (shipped)
- On company provisioning a "General" team (capital G,
source_identifier=Launchpad) is created viaCreateTeamForCompany(ctx, companyID, companySSOID, name)(create.go:22-28) — no members, actoruuid.Nil. - If synchronous creation fails, a
CREATE_GENERAL_TEAMjob (consts/worker.go:14) is enqueued and retried (MaxFails: 3). The consumer (consumer/create_general_team.go) is idempotent — it checks for an existing General team before creating.
7.6 BIF-8801 — Chat/CRM Heimdall migration triggers (shipped)
ChatClient.TriggerMigration/CrmClient.TriggerMigration(api/chat/trigger_migration.go,api/crm/trigger_migration.go) POST{ team_id, app_identifier_id, company_sso_id }to the source system with aBearertoken via the heimdall client (timeout + retry);200/201/202= success, otherwiseErrFailedToProcessRequest.- ⚠️ The exact endpoint path and auth scheme still carry
TODO(BIF-8801)markers pending sign-off with the Chat/CRM squads (trigger_migration.go:22-24,40-41).
7.7 BIF-8789 / 8835 — Mandatory invite team & teams in user responses (shipped)
- BIF-8789. On SSO invite when the
launchpad_one_team_migrationflag is on,TeamIdsmust be non-empty and every team must belong to the inviter's company; matchingteam_usersrows are written during the invite. - BIF-8835.
ListTeamsByUsernow returns team names, surfaced as ateamsarray (response.TeamInfo) onGET /users/me,SsoGetUserDetail, andPOST /private/users/get_by_sso_id. Each entry is now{ id, name, children }(see §7.8, BIF-8864). Population is best-effort — a DB error logs and returns an empty list rather than failing the response.
7.8 BIF-8864 — Recursive children tree in user responses (shipped)
- Shape.
response.TeamInfogained a nullableChildren *[]TeamInfo(user_response.go:11-15). Each of the user's direct teams now carries its full recursive descendant hierarchy; a team with no children serialiseschildrenas JSONnull. - Built in-process, no N+1.
buildTeamInfoWithChildren/buildChildrenTree(users/team_helpers.go) build the tree from a singleListTeamHierarchyread per request. A DFSpathSet(backtracked viadefer) guards against circularparent_idreferences while still handling diamond-shaped hierarchies. The hierarchy read is skipped entirely when the user has no team memberships. - Cache. A cache-aside snapshot keyed
teams:hierarchy:{company_sso_id}(24h TTL) avoids repeated full-table scans; it is invalidated (cache.Del) alongside the existing team-list cache on every team create/update/delete (teams/cache.go). - Surfaced by
GetInfo(/users/me),SsoGetUserDetail, andPrivateGetUserDetailBySsoId(/private/users/get_by_sso_id). Note thisTeamInfo.Childrenis distinct from the hierarchy-endpoint'sTeamHierarchyResponse.Children(§5.1.5).
Example — a user response fragment (from GET /users/me / POST /private/users/get_by_sso_id) showing the nested tree. The user is a direct member of Sales and Support; each direct team lists its full descendant hierarchy, and leaf teams serialise children as null:
{
"data": {
"id": "user-uuid",
"teams": [
{
"id": "sales-uuid", "name": "Sales",
"children": [
{
"id": "sales-north-uuid", "name": "Sales North",
"children": [
{ "id": "sales-north-jkt-uuid", "name": "Sales North Jakarta", "children": null }
]
},
{ "id": "sales-south-uuid", "name": "Sales South", "children": null }
]
},
{ "id": "support-uuid", "name": "Support", "children": null }
]
}
}
7.9 BIF-8865 — Private (service-to-service) team endpoints (shipped)
- Full CRUD + list/detail/members/hierarchy exposed under
/private/teams(Basic auth) for callers without an end-user SSO session; company supplied ascompany_sso_id. See §5.2 for the endpoint contracts and ADR-9 for the auth rationale. - New
ITeamService*Privatemethods (teams/main.go) delegate to the same service core as the SSO surface — the same validation, hierarchy/collision rules, cache refresh, and Kafka events apply. Tenant isolation on{id}ops viavalidateTeamBelongsToCompany(403on mismatch). No menu-visibility variant. (An async private/bulkcounterpart was added later — see §7.11.)
7.10 BIF-8866 — Full Kafka coverage of team-membership actions (shipped)
- Member-add paths that previously bypassed the event system now publish
TEAM_UPDATED(update_mask=["members"]) viaPublishTeamMembersUpdated:SsoInvite,provisionExistingSSOUser(users/sso_invite.go), and userUpdatewithTeamIDs(users/update.go) — complementing the pre-existing remove paths (users/delete.go,users/private_delete_by_email.go). See §6.5. - Delete-event hardening.
DeleteTeamnow sourcescompany_sso_idfrom the already-validated user record instead of an extraGetCompanyAndPackageByIdlookup, removing a failure mode that could silently skipTEAM_DELETED(teams/delete.go). - Guardrails. User update enforces an IDOR check (each assigned team must belong to the target user's company, else
ErrForbidden) and a 50-team cap (request/user_request.go).
7.11 Private (S2S) bulk-create endpoints (shipped)
- Adds a Basic-auth, service-to-service counterpart of the SSO bulk flow (§7.2/§7.3) under
/private/teams/bulk(BulkCreateTeamPrivate) +/private/teams/bulk/{upload_id}(GetBulkCreateTeamStatusPrivate), registered before/{id}ininternal/server/rest_router.go. - Reuses the same service core:
BulkCreateTeamPrivatevalidates the samerequest.BulkCreateTeambody, callsEnqueueBulkCreateTeam, and returns 202 +upload_id. Because there is no user session, the job is attributed to the system actoruuid.Nilinstead of a caller SSO id. GetBulkCreateTeamStatusPrivatescopes the lookup to the system actor (GetTeamUploadJobForCaller(ctx, uploadID, uuid.Nil.String())), so it surfaces only private-path jobs; an unknown or non-privateupload_idreturns404. See §5.2.8–5.2.9 for the full contract and ADR-6 for the scoping rationale.
8. Architecture Decisions (ADR)
ADR-1 — Bulk create is async via the worker, not synchronous
- Context. Migration submits large multi-company batches; a synchronous request risks gateway timeouts.
- Decision. Validate + persist a
team_upload_jobsrecord, enqueueBULK_CREATE_TEAM, return 202 +upload_id. Mirror the provenMIGRATE_COMPANY_FULLpattern (migrate_full.go:81-139,migrate_company_full.go:21-47). - Rejected. Synchronous bulk insert — simpler but unbounded latency; rejected.
- Consequences. Client must poll (BIF-8609). At-least-once worker retries →
ProcessBulkCreateTeammust be re-entrant. - Reversibility. High — endpoint is additive.
ADR-2 — Cache key = company_sso_id
- Decision.
teams:company:{company_sso_id}, whole list as one JSON value, TTL 24h, write-through rebuilt from DB on each mutation. Matches the existingusers:{companySsoId}convention. - Rejected. Per-internal-
company_idkey (not the cross-product contract Chat/CRM use); per-team keys (more invalidation complexity). - Consequences. Coarse granularity / write amplification; accepted — team counts per company are small (tens). Hot-key/last-writer-wins races mitigated by rebuilding from DB truth + 24h backstop; a
SetNxlock can be added later if ordering matters. - Reversibility. High.
ADR-3 — Name-collision handling on migrate = app prefix
- Decision. Keep the company-scoped unique-name rule; on collision with
is_migrate && app, retry once as"{app}-{name}"; if still taken, fail with the existing error. - Rejected. Dropping uniqueness, or silently suffixing with counters — both opaque to consumers.
- Reversibility. High — gated by
is_migrate.
ADR-4 — Kafka publish is after-commit and non-blocking
- Decision. Publish only after the DB tx commits; log+swallow publish errors. Matches
maybePublishCompanySettingsUpdatedEvent. - Consequences. At-least-once + possible lost publish on Kafka outage; consumers must be idempotent on
event_id; DB remains source of truth. Self-heals on next mutation (and via 8607 cache rebuild). - Reversibility. N/A — already shipped.
ADR-5 — Reuse the single-create core in the bulk path
- Decision. Extract
createSingleTeam(ctx, companyID, companySSOID, item); bothCreateTeamand the worker call it (incl. 8607 migrate/prefix + cache refresh). - Rejected. Duplicating create logic in the worker — drift risk.
- Reversibility. Medium (refactor).
ADR-6 — team_upload_jobs is cross-company (no company_id); status authz by creator
- Context. A single batch spans many companies, so the row cannot belong to one company.
- Decision. No
company_idcolumn; carrycreated_by_sso_idand per-itemcompany_sso_idinsideresult. BIF-8609 authorizes bycreated_by_sso_id == caller. - Consequences. Shipped as creator-scoped (
created_by_sso_id == caller); the original "scope by caller's company" design was dropped (OQ-3 resolved). A shared service account can read all its own uploads (intended trust boundary). The private bulk endpoints (§5.2.8–5.2.9) apply the same rule with the system actoruuid.Nilas the creator, so their status lookups surface only private-path jobs. - Reversibility. Low — contract shipped.
ADR-7 — Source-tracking idempotency & TEAM_MIGRATED vs TEAM_CREATED (BIF-8798/8799)
- Decision. Persist
(source_identifier, reference_id)per team with a partial unique index; migrate-mode creates are idempotent viaFindTeamBySourceRefand emit a distinctTEAM_MIGRATEDevent so consumers can tell a migration from an organic create. - Rejected. Reusing
TEAM_CREATEDwith a boolean flag (coarser for consumers); overloadingnameuniqueness for idempotency (collides with the app-prefix rule). - Reversibility. Low — the column contract and event type are consumed downstream.
ADR-8 — User-response team hierarchy is built in-process, not per-team queried (BIF-8864)
- Context. Each user response must carry the descendant tree of every team the user belongs to.
- Decision. Read the company's full hierarchy once (
ListTeamHierarchy, cache-aside onteams:hierarchy:{company_sso_id}) and assemble each team's children in memory with a DFS that carries a per-path ancestor set to break cycles. - Rejected. A per-team recursive query (N+1 under load); a DB-side recursive CTE per request (repeated full scans, harder to cache).
- Consequences. One read per request (usually a cache hit); the cache must be invalidated on every team mutation (done in
teams/cache.go). Malformed cyclicparent_iddata cannot cause infinite recursion. - Reversibility. High — additive response field + helper.
ADR-9 — Private team endpoints authenticate by Basic auth + company_sso_id, not a user session (BIF-8865)
- Context. Chat/CRM back-ends need team CRUD without an end-user Mekari SSO token.
- Decision. Expose parallel
*Privatehandlers under the Basic-auth/privategroup; take the company explicitly ascompany_sso_id; record the actor asuuid.Nil. Enforce tenant isolation per request viavalidateTeamBelongsToCompany. - Rejected. Reusing the SSO middleware (no user token available S2S); a separate microservice (needless duplication of the team core).
- Consequences. Two handler sets over one service core; event actor ids are nil for private-origin mutations. Callers of the private surface are trusted (Basic-auth boundary) but are still scoped to the
company_sso_idthey pass. - Reversibility. High — additive endpoints.
9. High-Availability & Reliability
- At-least-once delivery: consumers must be idempotent on
event_id. - Ordering: partitioned by
team_id— all events for a team arrive in order. - Non-blocking publish: Kafka failure is logged, never rolls back the mutation (
events.go:230-290);update.go:170publishes on a detached 5s-timeout context so a client disconnect after commit cannot abort it. - DLQ: consumers should implement a DLQ after N processing retries.
- Bulk worker:
MaxFailsre-runs the whole job →ProcessBulkCreateTeammust skip already-created teams (rely on per-company name uniqueness). - Feature flag: per-company rollout via
launchpad_publish_team_update.
10. Security Considerations
- No PII in payloads: only IDs (
user_id,sso_id); consumers resolve names from their own store. - Kafka is VPC-internal: no external exposure.
- ✅ Payload scoping (OQ-1 resolved). All team events now carry
company_sso_id, so consumers can verify each event belongs to a company they manage without an extra lookup. - Bulk authz (OQ-3): status endpoint must scope by
created_by_sso_id, not company; non-creator → 404. Bulk POST authorizes the caller (service/privileged token), not the items' companies, and must not 403 on caller/company mismatch. - IDOR on members: the member
CompanyID == team.CompanyIDguard now uses the per-item company in the bulk path — a member SSO id from another company is recorded as a failed item, not a cross-tenant leak. - Private surface (BIF-8865):
/private/teamsis Basic-auth-gated and cross-company by design — each request is scoped to thecompany_sso_idit carries, and{id}operations verify the team belongs to that company (validateTeamBelongsToCompany), returning403otherwise. Guard the Basic-auth credentials as a privileged S2S secret. - IDOR on user team-assignment (BIF-8866): user update rejects any
team_idwhose company differs from the target user's (ErrForbidden) and caps assignments at 50 teams, preventing cross-company membership via the newTEAM_UPDATEDpublish.
11. Rollout Plan
| Step | Action | Owner | Status |
|---|---|---|---|
| 1 | Create topic bifrost.team.events.v1 | DevOps/SRE | done |
| 2 | Implement Launchpad producer (create/update/delete) | Bifrost BE | done (BIF-8603/04/05) |
| 3 | delete-user → TEAM_UPDATED flow | Bifrost BE | done (BIF-8606) |
| 4 | Enable flag for pilot company in staging | Bifrost BE | in progress |
| 5 | Migrate-aware create + Redis cache | Bifrost BE | done (BIF-8607) |
| 6 | Async bulk create + worker | Bifrost BE | done (BIF-8608) |
| 7 | Bulk status endpoint | Bifrost BE | done (BIF-8609) |
| 8 | Chat consumer on the topic | Chat Squad | pending |
| 9 | CRM consumer on the topic | CRM Squad | pending |
| 10 | Enable flag for all companies; monitor Datadog (topic lag, error rate) | Bifrost BE + SRE | pending |
| 11 | Source-tracking columns + pushed-migration receive path (TEAM_MIGRATED) | Bifrost BE | done (BIF-8798/8799) |
| 12 | General-team auto-provision + async retry; Heimdall migration triggers | Bifrost BE | done (BIF-8685/8800/8801) |
| 13 | Mandatory team on SSO invite; teams in user responses | Bifrost BE | done (BIF-8789/8835) |
| 14 | Recursive children tree in user responses + hierarchy cache | Bifrost BE | done (BIF-8864) |
| 15 | Private (S2S) team endpoints under /private/teams | Bifrost BE | done (BIF-8865) |
| 16 | Full Kafka coverage of team-membership actions + delete-event hardening | Bifrost BE | done (BIF-8866) |
| 17 | Private (S2S) bulk-create endpoints under /private/teams/bulk | Bifrost BE | done |
12. Delivery History
All teams work described in this RFC has shipped. Where each ticket landed:
| Ticket | Delivered |
|---|---|
| BIF-8603/8604/8605 | TEAM_CREATED/UPDATED/DELETED publishing (teams/events.go) |
| BIF-8606 | delete-user → per-team TEAM_UPDATED (users/delete.go, PublishTeamMembersUpdated) |
| BIF-8607 | migrate-aware create + Redis cache (teams/create.go, teams/cache.go, ListAllTeamByCompany) |
| BIF-8608 | async bulk create (teams/bulk_create.go, consumer/bulk_create_team.go, team_upload_jobs) |
| BIF-8609 | creator-scoped status endpoint (GetTeamUploadJobForCaller) |
| BIF-8685/8800 | General team on company create + CREATE_GENERAL_TEAM retry; company_sso_id in payloads |
| BIF-8798/8799 | source-tracking columns, FindTeamBySourceRef, TEAM_MIGRATED |
| BIF-8801 | Heimdall Chat/CRM migration triggers (api/{chat,crm}/trigger_migration.go) |
| BIF-8789/8835 | mandatory invite team; teams in /users/me & /private/users/get_by_sso_id |
| BIF-8864 | recursive children tree on TeamInfo (users/team_helpers.go); teams:hierarchy:* cache (teams/cache.go) |
| BIF-8865 | private team endpoints (teams/*_private.go, validate_team_access.go, /private/teams routes) |
| BIF-8866 | member-add flows emit TEAM_UPDATED (users/sso_invite.go, users/update.go); DeleteTeam event hardening |
| Private bulk create | private bulk-create endpoints (BulkCreateTeamPrivate, GetBulkCreateTeamStatusPrivate, /private/teams/bulk routes); system-actor (uuid.Nil) attribution + scoped status |
Quality gate for any further change: make prepare (build + mocks + test + lint + sec) with go fmt/go vet/staticcheck/gosec clean; update swagger via make init.
13. Open Questions
| # | Severity | Question | Owner |
|---|---|---|---|
| OQ-1 | RESOLVED | company_sso_id was added to TEAM_CREATED, TEAM_UPDATED and TEAM_MIGRATED payloads (events.go:29,51,92). Consumers scope by payload; no extra lookup needed. | Bifrost + Chat/CRM |
| OQ-2 | [important] | Is company_sso_id always populated? Teams created before the SSO-ID backfill may have gaps, which silently skips publishing (events.go:90,166,234). | Bifrost BE |
| OQ-3 | RESOLVED | Shipped creator-scoped: the status endpoint returns a job only when created_by_sso_id == caller, else 404 (GetTeamUploadJobForCaller; ADR-6). | Bifrost BE |
| OQ-4 | RESOLVED | Granular TEAM_MEMBER_ADDED/REMOVED events are not needed: BIF-8866 makes every membership mutation — add (invite/provision/user-update) and remove (user delete / delete-by-email) — emit a full-member-list TEAM_UPDATED (update_mask=["members"]). Consumers reconcile from the member list on each event. | Chat + CRM |
| OQ-5 | RESOLVED | Cap = 500 (maxBulkTeamBatchSize, team_request.go:119); overflow → 400 "batch size must not exceed 500". | Bifrost BE |
14. Ready for Agent Execution
Ready for agent execution: yes. Both previously-blocking [critical] questions are resolved — company_sso_id is in every team payload (OQ-1) and the bulk status endpoint is creator-scoped (OQ-3). All features documented in this RFC are merged.
OQ-4 is now resolved (BIF-8866 gives full-member-list TEAM_UPDATED coverage on every add/remove path), leaving one non-blocking open question: OQ-2 (pre-backfill company_sso_id gaps silently skip publishing). The Chat/CRM consumers (§11 steps 8–9) and the Heimdall trigger endpoint contract (§7.6) remain owned by those squads.