Skip to main content

RFC: Launchpad Teams

StatusRFC
TypeBackend (Go service — qontak-launchpad)
OwnerQontak Bifrost
Submitted Date6/11/2026
Last Updated7/21/2026
Repobitbucket.org/terbang-ventures/qontak-launchpad
Related Documentshttps://jurnal.atlassian.net/wiki/spaces/QON/pages/49655742465
Tracking TicketsBIF-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

#SectionWhat it answers
1Overview & ContextWhat a Team is, current state, goals, scope
2Infrastructure & TopologyWhere this runs; pods, DB, cache, queue, consumers
3Repo Reading GuideFiles an agent must read first (with anchors)
4Data Modelteams (+ source tracking), team_users, team_upload_jobs
5API EndpointsAll team endpoints (auth, request, response)
6Domain Event Flows (Kafka)Create/Update/Delete + delete-user flow (sequence diagrams)
7Migration & Source-Tracking (as-built)BIF-8607/8608/8609 + 8685/8789/8798/8799/8800/8801/8835/8864/8865/8866
8Architecture Decisions (ADR)Async, cache key, collision strategy, authz
9HA & ReliabilityDelivery, ordering, non-blocking publish
10SecurityPII, authz
11Rollout PlanOrdered steps + owners
12Delivery HistoryShipped tickets & where they landed
13Open QuestionsBlockers tagged by severity
14Ready 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_id in 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 recursive children tree 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 (not sso_id), so the join is always company-scoped.
TableKey ColumnsNotes
teamsid, company_id, name, parent_id, source_identifier, reference_id, created_at, updated_atparent_id self-referencing (NULL = root); source_identifierChat/CRM/Launchpad (default Launchpad); unique (company_id, source_identifier, reference_id) where reference_id not null
team_usersid, team_id, user_idFK to teams.id and users.id, both ON DELETE CASCADE; UNIQUE(user_id, team_id) — inserts use ON CONFLICT DO NOTHING (idempotent)
usersid, sso_id, company_idsso_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 recursive children tree 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

ComponentResponsibilityThird-party / data deps
internal/serverHTTP 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_UPDATEDPostgres, 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
RedisSSO-auth cache (existing); per-company team-list snapshot (BIF-8607); job backend
KafkaSingle topic bifrost.team.events.v1, partitioned by team_id
qontak-preferencesPer-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

FileAnchorWhat to learn
internal/app/service/teams/main.go:32-38TeamService struct fields (repo, notificationService, kafkaProducer) + NewTeamService(repo, chatClient, crmClient, kafkaProducer) + ITeamService interface to extend
internal/app/service/teams/events.go:18-317As-built event structs + publish helpers — copy these payload shapes verbatim into §6
internal/app/service/teams/create.go:15, :109Create flow; maybePublishTeamCreatedEvent called after tx commit
internal/app/service/teams/update.go:17, :170Update flow; publish on a detached 5s-timeout context after commit
internal/app/service/teams/delete.go:12, :42Delete flow; maybePublishTeamDeletedEvent (fully non-blocking)
internal/app/service/teams/list.go:16, :38ListTeamByCompany projection — reuse its shape for the BIF-8607 cache
internal/app/service/users/delete.go:20-31, :145-183delete-user → team-member flow: strip team_users in-tx, publish TEAM_UPDATED per affected team after commit
internal/kafka/topics.go:9, :15TopicCompanySettingsEventsV1 (template) and TopicTeamEventsV1 = "bifrost.team.events.v1"
internal/pkg/constants/preferences.go:16, :26FeaturePublishCompanySettingUpdate (template) and FeaturePublishTeamUpdate = "launchpad_publish_team_update"
internal/app/service/companies/edit.go:297-372maybePublishCompanySettingsUpdatedEvent — the publish-guard pattern team events follow
internal/app/service/companies/migrate_full.go:81-139EnqueueMigrateCompanyFull (enqueue at :111) — async-job template for BIF-8608
internal/app/consumer/migrate_company_full.go:21-47MigrateCompanyFullConsumer — consumer template for BIF-8608
internal/app/queue/job_enqueuer.go:14-16IJobEnqueuer.EnqueueJob(ctx, job_name, params)
internal/pkg/consts/worker.go:12MigrateCompanyFullJobName = "MIGRATE_COMPANY_FULL" — add BulkCreateTeamJobName beside it
internal/worker/worker_service.go:48, :63registerJob — where to register BULK_CREATE_TEAM
db/query/team.sql:1-14617 existing queries; no ListAllTeamByCompany yet (add for BIF-8607)
db/query/migration_jobs.sql:1-27CRUD 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-98NewTeamService(repo, chatClient, crmClient, producer) wiring; pass jobEnqueuer/cacheRepo for §7
internal/app/service/teams/create_private.godelete_private.goPrivate (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.govalidateTeamBelongsToCompany — enforces team.CompanyID == company.ID on private {id} ops (else 403)
internal/app/service/users/team_helpers.gobuildTeamInfoWithChildren/buildChildrenTree — recursive children tree for user responses (BIF-8864); DFS path-set guards circular parent_id
internal/app/service/teams/cache.goteams: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

  1. internal/app/service/teams/main.go
  2. internal/app/service/teams/events.go
  3. internal/app/service/teams/create.go
  4. internal/app/service/users/delete.go
  5. internal/kafka/topics.go
  6. internal/app/service/companies/migrate_full.go
  7. internal/app/consumer/migrate_company_full.go
  8. db/query/team.sql + db/query/migration_jobs.sql
  9. internal/server/rest_router.go
  10. cmd/initializer.go

3.3 Source Verification

Claim in this RFCEvidence
Topic = bifrost.team.events.v1internal/kafka/topics.go:15
Flag = launchpad_publish_team_updateinternal/pkg/constants/preferences.go:26
Publish is after-commit & non-blockingcreate.go:109, update.go:170, delete.go:42, events.go:230-290
TEAM_CREATED payload includes company_sso_idevents.go:24-30 (TeamCreatedPayload)
TEAM_UPDATED payload includes company_sso_idevents.go:44-52 (TeamEventPayload)
TEAM_DELETED payload has company_id + company_sso_idevents.go:67-72 (TeamDeletedPayload)
TEAM_MIGRATED event exists (migrate mode)events.go:85-182 (TeamMigratedPayload)
Delete-user strips team_users then publishes per teamusers/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/teamsinternal/server/rest_router.go:117-126
Handlers read caller from X-Authenticated-Useridteam_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 fieldinternal/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 as company_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.com in production, api.mekari.io in 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-Userid header (the user's sso_id). Every handler reads req.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 by PermissionCheck middleware.

  • Response envelope. Successful responses are wrapped by WriteJSON (internal/pkg/http/handler.go:25-36) as:

    { "data": <payload> }

    List endpoints place their pagination object inside data (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; 400 carries a human-readable description.

5.1 Endpoints (SSO surface — /iag/v1/teams)

#MethodPath (relative to base)HandlerAuth
5.1.1POST/CreateTeamSSO Bearer
5.1.2PATCH/{id}UpdateTeamSSO Bearer
5.1.3DELETE/{id}/deleteDeleteTeamSSO Bearer
5.1.4GET/ListTeamSSO Bearer
5.1.5GET/hierarchyListTeamHierarchySSO Bearer
5.1.6GET/{id}DetailTeamSSO Bearer
5.1.7GET/{id}/membersListMembersSSO Bearer
5.1.8POST/bulkBulkCreateTeamSSO Bearer
5.1.9GET/bulk/{upload_id}GetBulkCreateTeamStatusSSO Bearer

Routing note: the static /bulk/... segment takes precedence over GET /{id} so a bulk request does not resolve into DetailTeam (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

FieldTypeRequiredNotes
namestringteam name (company-scoped unique)
member_idsstring[]conditionalmember SSO ids; required (≥1) in normal mode, optional when is_migrate=true
parent_idstringparent team id; omit/empty = root team
is_migratebool❌ (default false)when true, a name collision is auto-disambiguated instead of failing
appstringconditionalallowed ""/chat/crm; required when is_migrate=true
app_identifier_idstringconditionalsource 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 appchat/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 200response.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)

FieldTypeRequiredNotes
namestringnew name
member_idsstring[]✅ (≥1)full member SSO-id list (replace)
parent_idstringnew 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 200response.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

ParamTypeDefaultAllowed
pageint1
per_pageint(default/limit-adjusted)
order_bystringnamename, members, updated_at
order_directionstringascasc, desc
querystringfuzzy match on team name

Response 200response.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 200response.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 bodyrequest.BulkCreateTeam ({ teams: [ CreateTeamItem... ] })

Each item = the create body plus a required company_sso_id:

FieldTypeRequiredNotes
company_sso_idstring (UUID)company the team is created under; resolved via GetCompanyBySsoId
namestringteam name
member_idsstring[]✅ (≥1)member SSO ids (must belong to the item's company)
parent_idstringparent team id within the same company
is_migrateboolBIF-8607 collision handling
appstringconditionalchat/crm; required when is_migrate=true
app_identifier_idstringconditionalsource 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 202response.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 200response.TeamUploadJobStatusResponse. statuspending / 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 request Validate()s that company_sso_id is present and a valid UUID (else 400). The company is resolved via GetCompanyBySsoId (unknown → 404).
  • Tenant isolation. {id} operations verify the team belongs to the given company via validateTeamBelongsToCompany; a mismatch returns 403.
  • Actor. Create/delete record the actor as uuid.Nil (no user identity), so the emitted event's *_by_sso_id is 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 /bulk counterpart (§5.2.8–5.2.9); there is still no menu-visibility endpoint.
#MethodPathHandlercompany_sso_id inResponse
5.2.1POST/private/teamsCreateTeamPrivatebodyCreateTeamResponse
5.2.2GET/private/teamsListTeamPrivatequeryListTeamResponse
5.2.3GET/private/teams/hierarchyListTeamHierarchyPrivatequery[]TeamHierarchyResponse
5.2.4GET/private/teams/{id}/membersListMembersPrivatequery[]ListMemberResponse
5.2.5GET/private/teams/{id}DetailTeamPrivatequeryDetailTeamResponse
5.2.6PATCH/private/teams/{id}UpdateTeamPrivatebodyUpdateTeamResponse
5.2.7DELETE/private/teams/{id}/deleteDeleteTeamPrivatequerystring
5.2.8POST/private/teams/bulkBulkCreateTeamPrivatebody (per-item)BulkCreateTeamResponse
5.2.9GET/private/teams/bulk/{upload_id}GetBulkCreateTeamStatusPrivateTeamUploadJobStatusResponse

Routing note: the static /bulk and /bulk/{upload_id} routes are registered before /{id} (internal/server/rest_router.go) so a bulk request resolves to the bulk handlers, not DetailTeamPrivate. Unlike §5.2.1–5.2.7, the bulk endpoints are cross-company by design (each item carries its own company_sso_id) — they take no top-level company_sso_id and perform no 403 tenant-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:

FieldTypeRequiredNotes
company_sso_idstring (UUID)company the team is created under; resolved via GetCompanyBySsoId
namestringteam name
member_idsstring[]✅ (≥1)member SSO ids (must belong to the item's company)
parent_idstringparent team id within the same company
is_migrateboolcollision auto-disambiguation
appstringconditionalchat/crm; required when is_migrate=true
app_identifier_idstringconditionalsource 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 200response.TeamUploadJobStatusResponse (identical shape to §5.1.9); statuspending / 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"]) via PublishTeamMembersUpdated — 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/teams endpoints (§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

PropertyValue
Topic namebifrost.team.events.v1 (internal/kafka/topics.go:15)
Partition keyteam_id (ordering per team)
Producerqontak-launchpad
Consumersqontak-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_DELETED also has company_id), so consumers can scope events to a company from the payload alone. Publishing is skipped when company_sso_id is 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-183events.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

PropertyValue
Flaglaunchpad_publish_team_update
Serviceqontak-preferences
Unique IDcompany_sso_id (per-company rollout)
When disabledskip 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) and app (chat|crm) to request.CreateTeam. When a name collides and is_migrate && app != "", retry with "{app}-{name}"; if that is also taken, return error: name has been taken.
  • Add a write-through Redis snapshot of the company team list, keyed teams:company:{company_sso_id}, TTL 24h, rebuilt from DB after every create/update/delete.
  • Requires a new non-paginated query ListAllTeamByCompany (same projection as ListTeamByCompany, no LIMIT/OFFSET/name filter) and injecting repository.ICacheRepository into TeamService.

7.2 BIF-8608 — Async bulk create (multi-company, worker-processed) (shipped)

  • POST /iag/v1/teams/bulk accepts { "teams": [ <item>, ... ] }; each item carries its own company_sso_id plus 8607's is_migrate/app. The batch spans companies; no single caller-company is derived.
  • Handler validates → inserts a team_upload_jobs row (status=pending, total=N, created_by_sso_id=caller) → enqueues BULK_CREATE_TEAM (gocraft/work) → returns 202 + upload_id.
  • Worker (ProcessBulkCreateTeam) resolves each item's company via GetCompanyBySsoId, calls the extracted single-create core (createSingleTeam), records per-item {company_sso_id, name, team_id, status, error}, bumps processed, and refreshes the 8607 cache once per affected company. Per-item failures never abort the batch; final status is completed / 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 }, mirroring GET /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, default Launchpad) + nullable reference_id, with a partial unique index (company_id, source_identifier, reference_id) where reference_id is not null (migration 20260707120000_add_source_tracking_to_teams).
  • Idempotency. In migrate mode with an app_identifier_id, create first calls FindTeamBySourceRef(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-cased app (chatChat), reference_id = app_identifier_id; otherwise Launchpad / null.
  • Event. Migrate-mode create emits TEAM_MIGRATED (not TEAM_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 via CreateTeamForCompany(ctx, companyID, companySSOID, name) (create.go:22-28) — no members, actor uuid.Nil.
  • If synchronous creation fails, a CREATE_GENERAL_TEAM job (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 a Bearer token via the heimdall client (timeout + retry); 200/201/202 = success, otherwise ErrFailedToProcessRequest.
  • ⚠️ 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_migration flag is on, TeamIds must be non-empty and every team must belong to the inviter's company; matching team_users rows are written during the invite.
  • BIF-8835. ListTeamsByUser now returns team names, surfaced as a teams array (response.TeamInfo) on GET /users/me, SsoGetUserDetail, and POST /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.TeamInfo gained a nullable Children *[]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 serialises children as JSON null.
  • Built in-process, no N+1. buildTeamInfoWithChildren/buildChildrenTree (users/team_helpers.go) build the tree from a single ListTeamHierarchy read per request. A DFS pathSet (backtracked via defer) guards against circular parent_id references 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, and PrivateGetUserDetailBySsoId (/private/users/get_by_sso_id). Note this TeamInfo.Children is distinct from the hierarchy-endpoint's TeamHierarchyResponse.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 as company_sso_id. See §5.2 for the endpoint contracts and ADR-9 for the auth rationale.
  • New ITeamService *Private methods (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 via validateTeamBelongsToCompany (403 on mismatch). No menu-visibility variant. (An async private /bulk counterpart 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"]) via PublishTeamMembersUpdated: SsoInvite, provisionExistingSSOUser (users/sso_invite.go), and user Update with TeamIDs (users/update.go) — complementing the pre-existing remove paths (users/delete.go, users/private_delete_by_email.go). See §6.5.
  • Delete-event hardening. DeleteTeam now sources company_sso_id from the already-validated user record instead of an extra GetCompanyAndPackageById lookup, removing a failure mode that could silently skip TEAM_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} in internal/server/rest_router.go.
  • Reuses the same service core: BulkCreateTeamPrivate validates the same request.BulkCreateTeam body, calls EnqueueBulkCreateTeam, and returns 202 + upload_id. Because there is no user session, the job is attributed to the system actor uuid.Nil instead of a caller SSO id.
  • GetBulkCreateTeamStatusPrivate scopes the lookup to the system actor (GetTeamUploadJobForCaller(ctx, uploadID, uuid.Nil.String())), so it surfaces only private-path jobs; an unknown or non-private upload_id returns 404. 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_jobs record, enqueue BULK_CREATE_TEAM, return 202 + upload_id. Mirror the proven MIGRATE_COMPANY_FULL pattern (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 → ProcessBulkCreateTeam must 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 existing users:{companySsoId} convention.
  • Rejected. Per-internal-company_id key (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 SetNx lock 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); both CreateTeam and 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_id column; carry created_by_sso_id and per-item company_sso_id inside result. BIF-8609 authorizes by created_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 actor uuid.Nil as 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 via FindTeamBySourceRef and emit a distinct TEAM_MIGRATED event so consumers can tell a migration from an organic create.
  • Rejected. Reusing TEAM_CREATED with a boolean flag (coarser for consumers); overloading name uniqueness 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 on teams: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 cyclic parent_id data 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 *Private handlers under the Basic-auth /private group; take the company explicitly as company_sso_id; record the actor as uuid.Nil. Enforce tenant isolation per request via validateTeamBelongsToCompany.
  • 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_id they 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:170 publishes 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: MaxFails re-runs the whole job → ProcessBulkCreateTeam must 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.CompanyID guard 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/teams is Basic-auth-gated and cross-company by design — each request is scoped to the company_sso_id it carries, and {id} operations verify the team belongs to that company (validateTeamBelongsToCompany), returning 403 otherwise. Guard the Basic-auth credentials as a privileged S2S secret.
  • IDOR on user team-assignment (BIF-8866): user update rejects any team_id whose company differs from the target user's (ErrForbidden) and caps assignments at 50 teams, preventing cross-company membership via the new TEAM_UPDATED publish.

11. Rollout Plan

StepActionOwnerStatus
1Create topic bifrost.team.events.v1DevOps/SREdone
2Implement Launchpad producer (create/update/delete)Bifrost BEdone (BIF-8603/04/05)
3delete-user → TEAM_UPDATED flowBifrost BEdone (BIF-8606)
4Enable flag for pilot company in stagingBifrost BEin progress
5Migrate-aware create + Redis cacheBifrost BEdone (BIF-8607)
6Async bulk create + workerBifrost BEdone (BIF-8608)
7Bulk status endpointBifrost BEdone (BIF-8609)
8Chat consumer on the topicChat Squadpending
9CRM consumer on the topicCRM Squadpending
10Enable flag for all companies; monitor Datadog (topic lag, error rate)Bifrost BE + SREpending
11Source-tracking columns + pushed-migration receive path (TEAM_MIGRATED)Bifrost BEdone (BIF-8798/8799)
12General-team auto-provision + async retry; Heimdall migration triggersBifrost BEdone (BIF-8685/8800/8801)
13Mandatory team on SSO invite; teams in user responsesBifrost BEdone (BIF-8789/8835)
14Recursive children tree in user responses + hierarchy cacheBifrost BEdone (BIF-8864)
15Private (S2S) team endpoints under /private/teamsBifrost BEdone (BIF-8865)
16Full Kafka coverage of team-membership actions + delete-event hardeningBifrost BEdone (BIF-8866)
17Private (S2S) bulk-create endpoints under /private/teams/bulkBifrost BEdone

12. Delivery History

All teams work described in this RFC has shipped. Where each ticket landed:

TicketDelivered
BIF-8603/8604/8605TEAM_CREATED/UPDATED/DELETED publishing (teams/events.go)
BIF-8606delete-user → per-team TEAM_UPDATED (users/delete.go, PublishTeamMembersUpdated)
BIF-8607migrate-aware create + Redis cache (teams/create.go, teams/cache.go, ListAllTeamByCompany)
BIF-8608async bulk create (teams/bulk_create.go, consumer/bulk_create_team.go, team_upload_jobs)
BIF-8609creator-scoped status endpoint (GetTeamUploadJobForCaller)
BIF-8685/8800General team on company create + CREATE_GENERAL_TEAM retry; company_sso_id in payloads
BIF-8798/8799source-tracking columns, FindTeamBySourceRef, TEAM_MIGRATED
BIF-8801Heimdall Chat/CRM migration triggers (api/{chat,crm}/trigger_migration.go)
BIF-8789/8835mandatory invite team; teams in /users/me & /private/users/get_by_sso_id
BIF-8864recursive children tree on TeamInfo (users/team_helpers.go); teams:hierarchy:* cache (teams/cache.go)
BIF-8865private team endpoints (teams/*_private.go, validate_team_access.go, /private/teams routes)
BIF-8866member-add flows emit TEAM_UPDATED (users/sso_invite.go, users/update.go); DeleteTeam event hardening
Private bulk createprivate 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

#SeverityQuestionOwner
OQ-1RESOLVEDcompany_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-3RESOLVEDShipped creator-scoped: the status endpoint returns a job only when created_by_sso_id == caller, else 404 (GetTeamUploadJobForCaller; ADR-6).Bifrost BE
OQ-4RESOLVEDGranular 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-5RESOLVEDCap = 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.