[PRD] Enable Team Owner Field & Team Permission in CDP
Supersedes: [PRD] Enable Team Owner Field and Team Permission in CDP (page 51184435700). Why v2.0: Validated against
contact-service,qontak-customer-fe,qontak.comlegacy CRM,qontak-launchpad. Several assumptions did not hold. This version is grounded in code (see Appendix A). v2.3 change: Team Owner is now a multi-select field (team_owner_ids— an array), and auto-fill populates ALL of the creator's teams (not a single earliest team). v2.4 change: TEAM ONLY scoping now also covers Contact Notes (customers_customernotes_view/_manage/_delete) across API, web, and mobile (mobile-qontak-crm). Notes inherit their parent contact's team scope. v2.5 change: TEAM ONLY logic refined to match production/legacy: (1) an emptyteam_owner_ids(Unassigned) is visible to ALL Team Only users; (2) hierarchy traversal — a viewer also sees contacts owned by the descendant (child) teams of their teams. Migration source corrected tocrm_team_hierarchy_id. v2.6 change (reformat): Restructured to the canonical Qontak template — sections reordered + renumbered; the 17 prose stories + 9 negative scenarios converted to the native Section-10 story table (per-storyTEAM-S0XIDs, strict Gherkin); Frontend/Mobile change sections folded into Feature Changes; added API & Webhook Behavior (§9), Launch Plan & Stage Gates (§14), and a Dependency Graph. No content meaning changed — see Changelog.
✅ Reformat Complete — No Flags
All mandatory sections were preserved and reshaped to the canonical template; no content gaps were introduced. The only pending item is Figma (Header Figma Master = TBD) — design for the Team Owner multi-select + Team Only permission radio is not yet linked.
HEADER BLOCK
| Field | Value |
|---|---|
| PM | Zhelia Alifa |
| PRD Version | 2.7 |
| Status | DRAFT |
| PRD Type | ADJUSTMENT |
| Epic | TF-3185 |
| Squad | CDP Squad |
| RFC Link | N/A — to be created |
| Figma Master | TBD — Team Owner multi-select + Team Only permission radio |
| Anchor | No — standalone adjustment under PRD 25Q4 — Permission Completeness and Adjustment for CDP |
| Labels | epic:qontak-cdp | module:customers | feature:team-permission |
| Last Updated | 2026-06-30 |
Table of Contents
- HEADER BLOCK
- 2. Adjustment Context
- 3. One-liner + Problem
- 4. What Happens If We Don't Build This
- 5. Target Users + Persona Context
- 6. Non-Goals
- Scope Changes
- 7. Constraints
- 8. Feature Changes
- 9. API & Webhook Behavior
- 10. System Flow + User Stories + ACs
- 11. Rollout
- 12. Observability
- 13. Success Metrics
- 14. Launch Plan & Stage Gates
- 15. Dependencies
- 16. Key Decisions + Alternatives Rejected
- 17. Open Questions
- Appendix A — Grounded Code References
- PRD CHANGELOG
2. Adjustment Context
| Field | Detail |
|---|---|
| Parent Anchor PRD | PRD 25Q4 — Permission Completeness and Adjustment for CDP |
| This adjustment covers | (1) A new Team Owner field (multi-select) on the CDP contact, (2) meaningful enforcement of the TEAM ONLY access level for the four CDP customer permission actions (customers_customers_view, customers_customers_manage, customers_customers_delete, customers_customers_searchassoc), and (3) TEAM ONLY for Contact Notes — customers_customernotes_view, customers_customernotes_manage, customers_customernotes_delete — which inherit their parent contact's team scope (CHG-006, Stories 12–17). |
| Parent PRD still owns | All other CDP module permissions and the existing own / everything levels. |
| Reason for adjustment | The team permission level already exists in the backend (const.go:41) but is treated as full-org access — permission_service.go:86-89 returns true for every contact regardless of team. Teams need a real intermediate level. The Team Owner field (a set of team IDs) provides the stable scoping key. |
| Scope boundary | Launchpad permission/team APIs, contact-service query + enforcement (MongoDB), contact-service write/migration path, customer-fe UI, and mobile-qontak-crm (Flutter — CDP/Contact360 notes). Note: the Role & Permission settings UI is Launchpad-owned, not customer-fe — see CHG-001 and §15. |
3. One-liner + Problem
One-liner: Add a multi-select Team Owner to CDP contacts and enforce TEAM ONLY scoping, so agents see every contact owned by any of their teams.
Problem:
CDP exposes own, team, everything (const.go:40-42) — but team is unscoped: returns true for every company contact, identical to everything (permission_service.go:86-89). Admins have two unusable choices: over-restrict (own) or over-expose (everything). CDP also has no concept of which team(s) own a contact. Legacy CRM solves this with a single crm_team_id per person and a "Team Only" scope — but CDP needs multiple owning teams (collaborative hand-off across teams), and today has neither the field nor the filter.
Example: Zhelia ∈ {Product Management, Qontak Product}. On create, Team Owner auto-fills to both teams → team_owner_ids = [Product Management, Qontak Product]. With customers_customers_view = team, she sees every contact whose team_owner_ids includes either of her teams — plus contacts owned by any descendant (child) team of those teams (hierarchy traversal), plus any Unassigned contact (empty team_owner_ids).
- Case 1 — remove all
team_owner_ids→ empty. An empty/Unassigned owner is visible to every Team Only user; and via hierarchy traversal, managers in higher-level teams also see records from their subordinate (child) teams. - Case 2 — change to only Qontak Product. Only Qontak Product owns it; Product-Management-only members lose visibility — unless they qualify via owner/assignee (own records) or hierarchy traversal (a manager whose team is a parent of Qontak Product still sees it).
- Case 3 — change the owner. Customer X is owned by Zhelia with
team_owner_ids = {Product Management, Qontak Product}. When the owner is reassigned to Rara (∈ {Product Management, Qontak Designer}),team_owner_idsauto-re-derives to {Product Management, Qontak Designer} (Rara's teams) — team ownership follows the owner (see TEAM-S18).
4. What Happens If We Don't Build This
- Admins keep an unusable middle option.
teamsilently behaves likeeverything, so every tenant that wants team-scoped visibility must over-expose (everything) or over-restrict (own) — there is no correct setting. - Cross-team data exposure persists. Agents set to
teamtoday can see every contact in the company, a privacy/segregation gap for multi-team orgs and regulated accounts. - CRM-migrating clients lose a capability they had. Legacy CRM has a working "Team Only" scope (
crm_team_hierarchy_id+ descendant traversal); without this, clients moving to CDP regress to all-or-nothing visibility. - No ownership signal on contacts. CDP cannot express which team(s) own a contact, blocking collaborative hand-off and any future team-based reporting/routing.
5. Target Users + Persona Context
| Persona | Role | Goal | Pain (today) | Workaround |
|---|---|---|---|---|
| Primary — Org Admin / Supervisor | Configures Role & Permissions | Scope agents to team contacts without blocking collaboration | team silently behaves like everything | Set ALL ACCESS + informal naming conventions |
| Secondary — CS / Sales Agent | Handles contacts within their team(s) | View/act on contacts owned by any of their teams | Under own, cannot see any contact they don't personally own | Ask supervisor to manually reassign contacts |
6. Non-Goals
- No custom permission groups beyond the three standard levels (
own/team/everything). - No change to other CDP module permissions (Companies, Deals, Tickets, Conversations) — except
searchassoc. - No team-management UI inside CDP — teams and membership are owned by Launchpad.
- Downward hierarchy only. TEAM ONLY resolves the user's directly-assigned teams and their descendant (child) teams — managers see sub-team records, but a child-team member does not automatically see parent-team-owned records (traversal is downward only). (v2.5: reverses the v2.4 flat-only model — grounded in legacy
descendant_ids+ Launchpadteams.parent_id.) - No bulk re-assignment tool for Team Owner in v1 (single-record edit only).
- No change to export/audit permission rules beyond CHG-002.
customers_customers_profileviewandcustomers_customers_profilemanagepermissions are not in scope for TEAM ONLY (see OQ-6). (Contact Notes —customers_customernotes_*— ARE now in scope as of v2.4; see CHG-006 and Stories 12–17.)
Scope Changes
Engineering surfaces this PRD touches (controlled vocab). Kept in sync with the scope_changes frontmatter above.
- Backend —
contact-service: newteam_owner_idsarray field + multikey index, TEAM ONLY filter (empty-visible + downward hierarchy),EvaluatePermissionsextension, notes-scope enforcement (CHG-006), legacy backfill;qontak-launchpad: new teams-for-user(+descendants) API and theteampermission-level option. - Frontend —
qontak-customer-fe: Team Owner multi-select (MpInputTag), reads permissionlevel(not justis_enabled),/customers/teamsroute, notes view/add/edit/delete gating. - Mobile —
mobile-qontak-crm: render the Team Owner field and read per-notepermissionflags for CDP/Contact360 notes. - Design — Figma for the Team Owner multi-select field and the Team Only permission radio (currently TBD).
7. Constraints
| Constraint | Value |
|---|---|
| Platform | TEAM ONLY enforcement and Team Owner field apply to web (qontak-customer-fe) and mobile (mobile-qontak-crm, Flutter CDP/Contact360). Enforcement is server-side in contact-service; clients are UX only. Contact Notes scoping (CHG-006) applies on all three layers. |
| Datastore | contact-service stores contacts in MongoDB (BSON), not SQL. Team Owner is an array field team_owner_ids []string. TEAM ONLY is a BSON $or of: (a) multikey $in team_owner_ids: {$in: <viewer's teams + their descendant teams>}; (b) Unassigned team_owner_ids empty/missing ({$in: [null]} / {$size: 0} / {$exists: false}) → visible to all; (c) owner_id == user; (d) assignee_id == user. Backed by compound multikey index (company_sso_id, is_deleted, team_owner_ids). |
| Performance | TEAM ONLY contact list ≤ 2s P95. Filter uses stored team_owner_ids (indexed multikey $in). Viewer's team IDs + descendant team IDs resolved via ≤1 cached Launchpad call (hierarchy expansion via teams.parent_id; cache the expanded set, short TTL). |
| Feature flag | cdp_team_permission_enabled | default: OFF, enabled per company by Ops. (Not GetIsPermissionUsmanEnabled — that flag is company-wide and covers all 12 permissions; reusing it would change own/everything behavior for tenants with it already ON. A dedicated CDP-scoped flag is required — see CHG-005.) |
| Backward compatibility | Behaviour change risk: roles currently at level team see the whole org today; after this change they narrow to team scope. Must be gated by the per-tenant flag + migration/comms plan (CHG-005). |
| Plan scope | Plans with CDP Role & Permissions — Growth and Enterprise. Not Starter. |
| Implementation scope (contact-service) | team_owner_ids (array) must be added to 4 specific files: (1) internal/app/repository/contact/base.go — TeamOwnerIDs []string \bson:"team_owner_ids,omitempty"`(mirror the existingTags []stringatbase.go:75); (2) internal/app/payload/search_contact_request.go — add DTO field with **bson:"-"** (to bypass the generic primitive.A→$orexpansion at lines 138-157) + an explicitToFilters()branchfilters["team_owner_ids"] = bson.M{"$in": req.TeamOwnerIDs}; (3) internal/app/payload/contact_sync_request.go(write path, array); (4)internal/app/service/permission_service.go (EvaluatePermissions extended with **teamOwnerIDs []string** + **viewerTeamIDs []string** — BREAKING CHANGE). Additionally, **6 handler branches** in contact_handler.go(lines ~256, 466, 543, 648, 760, 956) that currently check onlyOwnerPermissionKeyneed correspondingTeamPermissionKey branches doing array-intersection (viewerTeamIDs ∩ contact.TeamOwnerIDs ≠ ∅`). |
| Implementation scope (notes) | Contact Notes have no team owner of their own — ContactNote (contact_notes/base.go:26-36) holds only contact_id, owner_id, note, company_sso_id. Notes inherit the parent contact's team_owner_ids. Enforcement is net-new in the notes service: today the notes handler does NOT call EvaluatePermissions, the middleware is a binary gate only, and ValidateContactExists returns a bool (no scope fields). See CHG-006. |
8. Feature Changes
CHG-001 — Add team (TEAM ONLY) to the Role & Permission settings UI
| Field | Detail |
|---|---|
| Change Type | Modified — Role & Permission settings (CDP Customers section) |
| Codebase owner | Launchpad Squad (or Admin Portal Squad — confirm which codebase hosts the permission settings UI). Not customer-fe. Level-update endpoint: PUT /iag/v1/users/{sso_id}/permissions/{permission_name}/level (Launchpad rest_router.go:172). |
| Before | Each of the four actions shows only {Owned Only, All Access}. |
| After | Each action shows {Owned Only, Team Only, All Access}, persisting own/team/everything via the existing level-update endpoint. |
CHG-002 — Enforce TEAM ONLY in contact-service (MongoDB)
| Field | Detail |
|---|---|
| Change Type | Modified backend — query scoping + single-record authorization + service signature extension |
| Datastore | MongoDB / BSON |
| Affected endpoints | GET /cdp/customers, GET /cdp/customers/{id} (+ by email/phone), DELETE /cdp/customers/{id}, GET /cdp/customers/search-assoc |
Filter semantics per level:
| Level | Server-side filter |
|---|---|
own | owner_id == user OR assignee_id == user — unchanged. |
team (NEW) | $or of: (a) team_owner_ids: {$in: <viewer's teams **+ descendant teams**>} (multikey intersection — matches if any owning team is one of the viewer's teams or a child team beneath them); (b) Unassigned — team_owner_ids empty/missing → visible to all Team Only users; (c) owner_id == user; (d) assignee_id == user. Viewer's expanded team set from the new Launchpad teams-for-user(+descendants) API (cached, short TTL). Viewer with no teams → still sees Unassigned + own/assigned records. |
everything | Company scope only — unchanged. |
disabled | No access; direct URL/API → 403. |
Implementation scope (all must be changed):
search_contact_request.go— addTeamOwnerIDs []stringto the DTO withbson:"-"(so it bypasses the genericprimitive.A→$orexpansion at lines 138-157), and an explicitToFilters()branch:if len(req.TeamOwnerIDs) > 0 { filters["team_owner_ids"] = bson.M{"$in": req.TeamOwnerIDs} }. This is the first$inin this file; the idiom is proven elsewhere (segmented_filter_request.go:211,field_properties_search.go:66).contact_handler.go— 6 branches at lines ~256, 466, 543, 648, 760, 956 (all currently checkOwnerPermissionKeyonly); each needs aTeamPermissionKeybranch that allows whencontact.TeamOwnerIDsis empty (Unassigned) ORexpandedViewerTeamIDs ∩ contact.TeamOwnerIDs ≠ ∅OR owner/assignee == user (else 403).expandedViewerTeamIDs= the viewer's teams plus their descendant teams.permission_service.go—EvaluatePermissionssignature extended to(permissions, userSSOID, ownerID, assigneeID, teamOwnerIDs []string, viewerTeamIDs []string)whereviewerTeamIDsis the expanded set (teams + descendants); theteambranch (currentlyreturn trueat lines 86-89) now returnstrueifteamOwnerIDsis empty (Unassigned) OR intersectsviewerTeamIDsOR owner/assignee match — BREAKING CHANGE.get_contact.go:22-26— single-get guard must add the team-intersection check alongside the company check.- Emit
cdp_team_permission_denied/cdp_team_permission_fallbackevents on denial or Launchpad-unavailable fallback.
CHG-003 — New Team Owner field on the CDP contact (Web + Mobile) — MULTI-SELECT
| Field | Detail |
|---|---|
| Change Type | New field — contact detail + create form |
| 4-file schema change | (1) internal/app/repository/contact/base.go — TeamOwnerIDs []string \bson:"team_owner_ids,omitempty"`(mirrorTags []stringatbase.go:75); (2) internal/app/payload/search_contact_request.go(DTObson:"-"+ToFilters() $inbranch); (3)internal/app/payload/contact_sync_request.go(write path, array); (4) MongoDB compound **multikey** index(company_sso_id, is_deleted, team_owner_ids)— new migration032, declared like any scalar index (MongoDB makes it multikey automatically; precedent: phone/name_search` arrays indexed plainly). |
| Editability | Editable, MULTI-SELECT. Options = the user's assigned teams (flat "My Teams" list, from the new teams-for-user API). Can be emptied entirely → Unassigned. |
| Default on create | Auto-fill = ALL of the creator's teams. No team → empty array (Unassigned). |
| Persistence | Stored as-is on manual edit. Exception — owner-change sync: when the contact's owner_id changes, team_owner_ids is auto-re-derived to the new owner's full team set (TEAM-S18, mirrors the create-time auto-fill in D-4), replacing the prior value. Not re-derived on unrelated saves or on the current owner later joining/leaving teams (OQ-2). |
| Validation | Server-side: every submitted team_owner_ids element must be a team the acting user belongs to; empty array allowed. Applies to web, mobile, OpenAPI. Reject with 422 if any element is outside the user's teams. |
| Display | Shows team-name chips; empty array → "Unassigned" (not blank). |
| API/sync writes | Same rule; no team → empty array (Unassigned). |
CHG-004 — Migration of existing contacts
| Step | Detail |
|---|---|
| 1. Identify source | Use crm_people.crm_team_hierarchy_id — the editable multi-team owner CSV (e.g. "3,7"), parsed by crm_team_hierarchy (person.rb:234-245). Corrected (v2.5): this is the real owner list, NOT crm_team_id (which is only the creator's auto-stamped home team). |
| 2. Split | crm_team_hierarchy_id.split(',') → list of legacy team integer IDs (one or many per contact). |
| 3. Reconcile | For each distinct legacy team ID, map to its Launchpad team UUID by team name within company. IDs are incompatible (integer vs UUID); map only. |
| 4. Map & set | Set team_owner_ids = [mapped_uuid, ...] — a multi-element array preserving every owning team (legacy was already multi-team). |
| 5. Fallbacks | crm_team_hierarchy_id null/empty or no name match → team_owner_ids = [] (Unassigned → visible to all under TEAM ONLY, per D-13). |
| 6. Report | Coverage + Unassigned count + unmatched teams before enabling enforcement. |
CHG-005 — Backward-compatibility + dedicated flag
| Field | Detail |
|---|---|
| Risk | team maps to full-org access today (permission_service.go:86-89). Any role at level team will silently narrow on enforcement. |
| Dedicated flag | Use cdp_team_permission_enabled | default: OFF — not GetIsPermissionUsmanEnabled. The existing flag is company-wide; reusing it changes own/everything behavior for tenants with it already ON (require_permission_middleware.go:21). |
| Mitigation | Audit existing team-level roles per tenant before enabling; gate behind dedicated flag; communicate change; enable progressively with monitoring. |
CHG-006 — Enforce TEAM ONLY for Contact Notes (customers_customernotes_view / _manage / _delete)
| Field | Detail |
|---|---|
| Change Type | Modified backend + web + mobile — notes inherit parent-contact team scope |
| Datastore | MongoDB / BSON |
| Affected permission keys | customers_customernotes_view, customers_customernotes_manage, customers_customernotes_delete (const.go:34-37). (Note creation customers_customernotes_add is governed separately and inherits the parent-contact visibility gate — see OQ-10.) |
| Affected endpoints | GET /contacts/{contact_id}/notes (list), GET .../{id}, PUT .../{id}, DELETE .../{id} — plus the deprecated duplicate group /contacts/notes/... (rest_router.go:150-158 + 161-168; both share handlers). |
| Core principle | Notes have no team owner of their own. A note's only team-relevant link is contact_id → parent contact. So customers_customernotes_* = team ⇒ "can the viewer see the parent contact under TEAM ONLY" — i.e. the full v2.5 rule applies transitively: parent team_owner_ids empty (Unassigned → all see) OR intersects {viewer's teams ∪ descendants} OR viewer is the contact's owner_id/assignee_id. Because notes are always fetched by a single contact_id, list/view scoping collapses to one parent-contact gate (no per-note filtering, no N+1). |
Implementation scope (contact-service):
- New parent-contact scope lookup.
ValidateContactExists(contact_notes/read.go) returns only a bool. Add a method returning the contact'sowner_id,assignee_id,team_owner_ids(FindOne projection); add it toContactNoteInterface(contact_notes/base.go:70-78); regenerate mocks. - Enforce in the notes SERVICE, not just the handler. Today the handler (
contact_notes_handler.go) does NOT callEvaluatePermissions; the middleware (require_permission_middleware.go:78-98) is a binary gate (Level != disabled) that stores the level in context but enforces no scoping. Add scope evaluation incontact_notes_service.gofor list / get / update / delete:everything→ allow;team→ allow if parentteam_owner_idsis empty (Unassigned) OR intersects {viewer's teams ∪ descendants} OR viewer owns/assigned the parent contact;own→ allow if viewer owns/assigned the parent contact. - Reuse the extended
EvaluatePermissions(CHG-002, D-10) — now takingteamOwnerIDs []string+viewerTeamIDs []string. - Fix
resolveNotePermission(contact_notes_handler.go:143-166) — it currently honors onlyeverything/ownand silently dropsteam. Add theteambranch so the per-noteUpdate/Deleteflags returned to clients reflect team scope (this is the seam both web and mobile read). - Plumb the permission level + viewer team IDs into the service (handler already reads
usmanPermission+userSSOIDfrom context). - Both route groups share handlers → one service change covers both.
Behavior change (confirm — OQ-11): Note update/delete are owner-only today regardless of level (contact_notes_service.go enforces note.owner_id == actor). Under team/everything, a team member / everything-level user should be able to manage/delete others' notes on an in-scope contact. This broadens current behavior — confirm product intent.
CHG-007 — Frontend (customer-fe) implementation (folded from the former "Frontend Changes" section)
| # | Change | Specific files to modify |
|---|---|---|
| FE-1 | Source the user's OWN teams for the picker + auto-fill. getTeams() is already wired (DetailPage.vue:139 onMounted), but it hits GET /v1/teams?...&per_page=10 (IAG Launchpad) — all company teams, capped at 10 — the wrong source. Add a store action that calls the new teams-for-user API (GET /iag/v1/users/me/teams) and use it for both auto-fill and the multi-select options. | features/customers/store/UserStore.ts (add getMyTeams() action + myTeams ref); features/customers/detail/views/DetailPage.vue:139 |
| FE-2 | Render the editable Team Owner multi-select on contact detail/create using MpInputTag (Pixel multi-tag). Fork common/components/field/MultipleSelect.vue into a TeamOwnerSelect.vue, sourcing options from myTeams and emitting team IDs (the existing MultipleSelect.vue emits names at lines 147-148 — the fork must emit item.id). Wire into the dynamic form in CustomerDetails.vue (field block ~106-127), not the legacy Profile.vue. | features/customers/detail/components/CustomerDetails.vue (field block ~106-127), new common/components/field/TeamOwnerSelect.vue, features/customers/store/CustomerStore.ts (add team_owner_ids?: string[] to Contact after line 304) |
| FE-3 | Surface the permission level (not just is_enabled). Update ListPage.vue:531-532 and DetailPage.vue:130-131 (currently perm?.is_enabled === true) to also read perm?.level. Update UserStore.ts hasAssociatedAccess() to return { enabled, level }. | features/customers/views/ListPage.vue:531-532, features/customers/detail/views/DetailPage.vue:130-131, features/customers/store/UserStore.ts:156 |
| FE-4 | Add /customers/teams route to sidebar; wire filterByRoute === 'teams' in ListPage.vue:190,367 to inject the team_owner_ids filter (same pattern as 'owned-by-me' → owner_id, 'assigned-to-me' → assignee_id). | components/Sidebar/SidebarChildCustomer.vue, features/customers/views/ListPage.vue:190,367, features/customers/views/components/Breadcrumb.vue |
| FE-5 | Pre-mount route guard instead of post-mount redirect. | middleware/authenticated.global.ts, pages/customers/add.vue |
| FE-6 | Notes (CHG-006): view/add gates read permission level, not just is_enabled. canViewNotes (CustomerActivityV2.vue:56-70) and canAddNotes (Notes.vue:83-97) currently check is_enabled only — same gap as FE-3. Per-note edit/delete already render the server-computed note.permission.update/.delete booleans (NotesList.vue:91-101) — so once the API computes team scope into those flags (CHG-006), web edit/delete follow automatically with no FE change. Add customers_customernotes_manage/_delete constants to Notes/constants.ts (only _add/_view exist today). | features/customers/detail/components/CustomerActivityV2/CustomerActivityV2.vue:56-70, .../Notes/Notes.vue:83-97, .../Notes/NotesList/NotesList.vue:91-101, .../Notes/constants.ts:27-28 |
(All FE gating is UX only; the security boundary is CHG-002 + CHG-006 server-side.)
CHG-008 — Mobile (mobile-qontak-crm) implementation (folded from the former "Mobile Changes" section)
Flutter / Dart (Melos monorepo, Clean Architecture + BLoC). CDP notes live in the crm_note package behind feature flags flag_contact_360 + flag_note_cdp.
| # | Change | Specific files to modify |
|---|---|---|
| MB-1 | Read each note's own permission, not the contact-level flag. The CDP API already returns per-note permission: {update, delete} and mobile parses it into NotePermission — but the UI ignores it, gating add/edit/delete on the contact-level permission.update flag (other_tab.dart:108). Change the note list/detail to gate on note.permission.update/.delete (already parsed). Once the API computes TEAM ONLY into those per-note booleans (CHG-006), no level enum is needed on mobile — it just works. | features/crm_note/.../presentation/screens/note/note_screen.dart:290, .../detail_note/detail_note_screen.dart:629, features/crm_contact/.../tab/other_tab.dart:108,146 |
| MB-2 | (If notes view must be team-gated as a section) plumb customers_customernotes_view — currently ABSENT on mobile; the Notes section visibility is gated only by feature flags. | features/crm_contact/.../tab/other_tab.dart:146; CDP permission plumbing |
| MB-3 | Render the Team Owner multi-select field on the CDP contact detail/create screen (same server-side validation as web). | features/crm_contact/... contact detail/create form |
(All mobile gating is UX only; the security boundary is CHG-002 + CHG-006 server-side. Endpoints are the same contact-service /v1/contacts/{id}/notes as web.)
9. API & Webhook Behavior
Server-side enforcement lives in contact-service; clients are UX only. The viewer's expanded team set (their teams + descendant teams) is resolved per request via the cached Launchpad teams-for-user(+descendants) API. "In team scope" below means: team_owner_ids empty (Unassigned) OR team_owner_ids ∩ expandedViewerTeamIDs ≠ ∅ OR owner_id/assignee_id == viewer.
| # | Behavior | Entity Affected | Triggered By | Expected Behavior | Failure Behavior |
|---|---|---|---|---|---|
| 1 | Persist a permission level | crs_permissions (Launchpad) | Admin saves a level in Role & Permission settings | PUT /iag/v1/users/{sso_id}/permissions/{permission_name}/level persists own/team/everything; settings page reflects it on reload | 5xx → inline error "Could not save permissions"; previous level remains active |
| 2 | List contacts (scoped) | CDP contacts (read) | GET /cdp/customers with customers_customers_view = team | Resolve expanded viewer teams (cached); apply BSON $or: Unassigned OR team_owner_ids {$in: teams+descendants} OR owner/assignee == viewer; return the scoped list | Launchpad teams API timeout (>500ms)/5xx → fall back to OWNED ONLY, banner shown, cdp_team_permission_fallback logged |
| 3 | Get / delete a single contact (scoped) | CDP contact (read/delete) | GET /cdp/customers/{id} (+ by email/phone), DELETE /cdp/customers/{id} under view/delete = team | Single-record guard (get_contact.go): return/allow only if the contact is in team scope; else 403. EvaluatePermissions extended with teamOwnerIDs + viewerTeamIDs (D-10) | Out-of-scope → 403 + cdp_team_permission_denied; Launchpad unavailable → OWNED ONLY fallback + cdp_team_permission_fallback |
| 4 | Association search (scoped) | CDP contacts (read, association) | GET /cdp/customers/search-assoc from Deal/Task/Ticket/Company under searchassoc = team | Returns only in-team-scope contacts (same $or filter); disabled → component hidden + API 403 | Same fallback as #2 |
| 5 | Write Team Owner on create/edit | CDP contact (team_owner_ids) | POST/PATCH /cdp/customers/{id} with team_owner_ids (web/mobile/OpenAPI) | Validate every element is one of the acting user's teams (empty allowed → Unassigned); persist the array; emit cdp_team_owner_set. On owner change: if the request changes owner_id, team_owner_ids is re-derived server-side to the new owner's full team set (TEAM-S18) — overriding any submitted/prior value — and cdp_team_owner_set is emitted with source = owner_change | Any element outside the user's teams → 422 + cdp_team_owner_validation_rejected; field reverts on the client. New owner's teams-for-user lookup unavailable → owner change still commits; re-derivation retried async (OQ-2) |
| 6 | List / get / update / delete contact notes (scoped) | CDP ContactNote (read/write) | GET/PUT/DELETE /contacts/{contact_id}/notes[/{id}] under customers_customernotes_{view,manage,delete} = team | Notes inherit the parent contact's scope (CHG-006): allow only if the parent contact is in team scope; service computes per-note permission.update/.delete returned to clients | Parent out-of-scope → 403; Launchpad unavailable → same OWNED ONLY fallback as #2 |
10. System Flow + User Stories + ACs
10.1 System Flow
Flow A — TEAM ONLY permission configuration + enforcement (11 steps)
- Admin opens Settings → Roles → [Role Name] → Permissions → CDP Customers section.
- Admin sees four permission actions (View, Manage, Delete, Search Assoc) each with three radio options.
- Admin selects Team Only for desired action(s) and saves.
- System calls
PUT /iag/v1/users/{sso_id}/permissions/{permission_name}/level(Launchpad) to persistteamlevel. - Settings page shows Team Only as the current value on reload.
- Agent with this role loads the Customers list (web or mobile).
contact-servicecalls the Launchpad teams-for-user(+descendants) API (cached, short TTL) to resolve the agent's team IDs and their descendant team IDs.- System applies BSON
$orfilter:team_owner_idsempty (Unassigned) ORteam_owner_ids: {$in: {teams + descendants}}ORowner_id == userORassignee_id == user. - Agent sees team-scoped + sub-team + Unassigned contacts on web and mobile.
- Agent opens a specific contact → same check applied on single-get (and on its notes — CHG-006).
- Failure branch: If Launchpad teams-for-user API unavailable (timeout > 500ms or 5xx) → fall back to OWNED ONLY; show banner; log
cdp_team_permission_fallback.
Flow B — Team Owner field on contact create
- Agent opens "Create Contact."
- FE calls the teams-for-user API; the multi-select shows "My Teams."
- Team Owner auto-fills to ALL of the creator's teams; creator with no team → empty (Unassigned).
- Agent optionally adds/removes teams or clears all to Unassigned.
- On save, contact-service validates: every
team_owner_idselement must be one of the user's teams (empty allowed). Stores the array.
📊 System Flow — TEAM ONLY Enforcement
sequenceDiagram
participant Admin
participant LP as Launchpad (permissions)
participant Agent
participant FE as customer-fe
participant CS as contact-service
participant LPT as Launchpad (teams-for-user API)
Admin->>LP: PUT /permissions/{name}/level {level: "team"}
LP-->>Admin: 200 saved
Agent->>FE: open Customers list
FE->>CS: GET /cdp/customers (IAG token)
CS->>LPT: GET /iag/v1/users/me/teams (+descendants, cached)
alt Launchpad unavailable
LPT-->>CS: timeout / 5xx
CS->>CS: fallback to own filter
CS-->>FE: own-scoped results
FE-->>Agent: banner "team filter temporarily unavailable"
else teams resolved
LPT-->>CS: teams + descendant team ids
CS->>CS: BSON $or: empty(Unassigned) OR team_owner_ids $in {teams+descendants} OR owner/assignee == user
CS-->>FE: filtered contact list
FE-->>Agent: team + sub-team + Unassigned contacts
end
Shared definition — access levels. TEAM ONLY: a contact is in scope if ANY holds — (a)
team_owner_idsis empty/Unassigned (visible to all Team Only users); (b)team_owner_ids ∩ ({viewer's teams} ∪ {descendant teams of viewer's teams}) ≠ ∅; (c)owner_id == viewer; (d)assignee_id == viewer. OWNED ONLY:owner_id == viewerORassignee_id == viewer. ALL ACCESS: company scope only. DISABLED: no access; direct URL/API → 403. NOTES (CHG-006): a note's scope = its parent contact's scope (so empty + hierarchy rules apply transitively).
10.2 User Stories
| User Story | Importance | Mockup | Technical Notes | Acceptance Criteria |
|---|---|---|---|---|
| [TEAM-S01] — Team Only radio available on all CDP permission actions As an Org Admin, I want every in-scope CDP permission action to expose a Team Only option (alongside Owned Only and All Access), so that I can scope any CDP customer/notes permission to team level. | Must Have | Figma: TBD — Launchpad Role & Permission settings (Team Only radio) | Data Fields: • permission_name (string) — any in-scope CDP customer/notes action key• level (enum: own/team/everything)Before-After Behavior: Before: CDP permission actions show only {Owned Only, All Access}. After: all in-scope CDP permission actions show {Owned Only, Team Only, All Access}; the chosen level is persisted to Launchpad. | — Happy Path — • AC-1: Given the admin opens the CDP permission settings, when the page loads, then every in-scope CDP permission action displays the Team Only radio option — the four customer actions ( customers_customers_view, customers_customers_manage, customers_customers_delete, customers_customers_searchassoc) and the three contact-notes actions (customers_customernotes_view, customers_customernotes_manage, customers_customernotes_delete) — each showing Owned Only | Team Only | All Access.• AC-2: Given any of these actions, when the admin selects Team Only and saves, then PUT /permissions/{name}/level is called with level = "team" and the page shows Team Only as the current value on reload.• AC-3: Given an action set to Team Only, when the admin selects Owned Only and saves, then the level updates to own.• AC-4 (out of scope): Given a CDP permission not in TEAM ONLY scope (e.g. customers_customers_profileview / customers_customers_profilemanage — Non-Goal 7), when the settings render, then it continues to show only its existing options — no Team Only radio is added.— Error / Unhappy Path — • ERR-1: Given the admin saves Team Only, when Launchpad returns 5xx, then the save fails with inline error "Could not save permissions. Please try again." and the previous level remains active. — Permission Model — • CAN: Org Admin and Owner roles. • CANNOT: CS/Sales Agent, Supervisor — no access to permission settings page. • Unauthorized: Settings page not rendered; direct URL → 403. — UI States — • Loading: radio group skeleton while levels load. • Empty: N/A (fixed action list). • Error: inline "Could not save permissions. Please try again." • Success: Team Only shown as the selected radio on save + reload. |
[TEAM-S02] — Enforce customers_customers_view (Website)As a System, I want to enforce customers_customers_view on web, so that contact visibility is correctly scoped by level. | Must Have | Figma: N/A — server-side enforcement | Data Fields: • customers_customers_view level (from crs_permissions)• team_owner_ids (array of UUIDs, on contact doc)Before-After Behavior: Before: web supports OWNED ONLY and ALL ACCESS only. After: TEAM ONLY added — contact index + detail filtered server-side by team_owner_ids intersection (+ descendants + Unassigned). | — Happy Path — • AC-1: Given customers_customers_view = team, when the user loads the Customer Index on web, then only contacts where team_owner_ids intersects the user's teams or their descendant teams, OR the contact is Unassigned, OR owner_id/assignee_id == user are returned.• AC-2: Given view = own, when they load the Index, then only owner_id == user OR assignee_id == user contacts are returned.• AC-3: Given view = everything, when they load the Index, then all company contacts are returned.• AC-4: Given view = team and a contact opened by direct URL, when contact-service evaluates, then it is returned only if in team scope (intersect/descendant/Unassigned/owner/assignee); otherwise 403.• AC-5: Given view = team and the user belongs to no team, when they load the Index, then they see Unassigned contacts plus contacts they own/are assigned to (no other-team leakage).• AC-6 (hierarchy): Given the user belongs to a parent team and a contact's team_owner_ids includes a descendant team, when they load under view = team, then the contact is returned (downward traversal; child-team member does NOT see parent-team records).• AC-7 (Unassigned): Given a contact has empty team_owner_ids, when any view = team user loads the list, then the contact is returned.— Error / Unhappy Path — • ERR-1: Given view = team, when the Launchpad teams-for-user API is unavailable (timeout > 500ms or 5xx), then contact-service falls back to OWNED ONLY, the agent sees banner "Showing only your contacts — team filter temporarily unavailable," and cdp_team_permission_fallback is logged with platform: web.— Permission Model — • CAN: Any active agent/supervisor with customers_customers_view ≠ disabled.• CANNOT: Users with view = disabled.• Unauthorized: Index entry point not rendered; direct URL to detail → 403. — UI States — • Loading: list skeleton. • Empty: "No contacts in your team yet." • Error: fallback banner + own-scoped results. • Success: team-scoped list. |
[TEAM-S03] — Enforce customers_customers_view (CRM Mobile App)As a System, I want to enforce customers_customers_view on CRM Mobile, so that contact visibility is identical to web for the same role settings. | Should Have | Figma: N/A — server-side enforcement (mobile renders results) | Data Fields: same as TEAM-S02 (view level; contact team_owner_ids).Before-After Behavior: Before: mobile supports OWNED ONLY + ALL ACCESS. After: TEAM ONLY added — same server-side filter as web; DISABLED hides the customer-list tab. | — Happy Path — • AC-1: Given view = team on mobile, when the user opens the Customer List screen, then only in-team-scope contacts are returned — identical server-side filter to web.• AC-2: Given view = disabled, when the user navigates to the Customers menu, then the customer-list tab is hidden entirely.• AC-3: Given view = disabled and the user opens a contact via deep link, when the app resolves the URL, then 403 is returned.— Error / Unhappy Path — • ERR-1: Given view = team, when the teams-for-user API is unavailable on mobile, then contact-service falls back to OWNED ONLY, the agent sees inline banner "Showing only your contacts — team filter temporarily unavailable," and cdp_team_permission_fallback is logged with platform: mobile.— Permission Model — • CAN: Active agents/supervisors with view ≠ disabled.• CANNOT: Users with view = disabled.• Unauthorized: Customer list tab hidden; deep link → 403. — UI States — • Loading: list skeleton. • Empty: "No contacts in your team yet." • Error: fallback banner. • Success: team-scoped list. |
[TEAM-S04] — Enforce customers_customers_manage (Website)As a System, I want to enforce customers_customers_manage on web, so that agents can only edit contacts within their permitted scope. | Must Have | Figma: N/A — server-side enforcement | Data Fields: customers_customers_manage level; contact team_owner_ids; submitted team_owner_ids (on edit).Before-After Behavior: Before: web supports OWNED ONLY + ALL ACCESS. After: TEAM ONLY added — agents edit only contacts within team scope; edit entry on the Customer Details page. | — Happy Path — • AC-1: Given manage = team, when the user opens a contact in team scope (intersect/descendant/Unassigned/owner/assignee), then the "Edit Customer" entry point is visible and the update action is permitted.• AC-2: Given manage = team, when they attempt to edit an out-of-scope contact via the API, then 403 is returned.• AC-3: Given manage = team editing the Team Owner field, when they submit a team_owner_ids array containing any team that is not one of their assigned teams, then the server rejects with 422 and the field reverts.— Error / Unhappy Path — • ERR-1: Given manage = disabled, when the agent views any contact, then all edit-related buttons are hidden and edit via OpenAPI returns 403.— Permission Model — • CAN: Users with manage = team or everything, within their scope.• CANNOT: manage = disabled; team users for out-of-scope contacts.• Unauthorized: Edit button not rendered; API → 403. — UI States — • Loading: edit form. • Empty: N/A. • Error: "Could not save. Please try again." • Success: updated, toast shown. |
[TEAM-S05] — Enforce customers_customers_manage (CRM Mobile App)As a System, I want to enforce customers_customers_manage on CRM Mobile, so that edit access is scoped to the agent's team regardless of platform. | Should Have | Figma: N/A — server-side enforcement | Data Fields: same as TEAM-S04. Before-After Behavior: Before: mobile supports OWNED ONLY + ALL ACCESS. After: TEAM ONLY added — edit entry via Customer index (⋮) and details (⋮). | — Happy Path — • AC-1: Given manage = team on mobile, when the user opens a contact within team scope, then (⋮) shows "Edit" and the action is permitted.• AC-2: Given manage = team, when they attempt to edit an out-of-scope contact via the API, then 403 is returned — same server enforcement as web.• AC-3: Given manage = disabled, when the user views any contact, then all edit entry points (⋮ on index, ⋮ on details) are hidden.— Permission Model — • CAN: manage = team or everything, within scope.• CANNOT: manage = disabled; team users for out-of-scope.• Unauthorized: Edit option hidden; API → 403. — UI States — • Loading: edit form. • Error: "Could not save." • Success: toast confirmation. |
[TEAM-S06] — Enforce customers_customers_delete (Website)As a System, I want to enforce customers_customers_delete on web, so that agents can only delete contacts within their permitted scope. | Must Have | Figma: N/A — server-side enforcement | Data Fields: customers_customers_delete level; contact team_owner_ids.Before-After Behavior: Before: web supports OWNED ONLY + ALL ACCESS. After: TEAM ONLY added — delete entry points on Customer Index + Customer Details. | — Happy Path — • AC-1: Given delete = team, when the user views a contact within team scope, then the Delete button is visible and delete succeeds.• AC-2: Given delete = team, when they attempt to delete an out-of-scope contact via the API, then 403 is returned.• AC-3: Given delete = disabled, when the user views any contact, then the Delete button is hidden (index + details) and delete via OpenAPI returns 403.— Permission Model — • CAN: delete = team or everything, within scope.• CANNOT: delete = disabled; team users for out-of-scope.• Unauthorized: Delete button not rendered; API → 403. — UI States — • Loading: confirmation modal. • Error: "Could not delete." • Success: contact removed, list refreshed. |
[TEAM-S07] — Enforce customers_customers_delete (CRM Mobile App)As a System, I want to enforce customers_customers_delete on CRM Mobile, so that delete access is scoped to the agent's team regardless of platform. | Should Have | Figma: N/A — server-side enforcement | Data Fields: same as TEAM-S06. Before-After Behavior: Before: mobile supports OWNED ONLY + ALL ACCESS. After: TEAM ONLY added — delete entry via Customer Index (⋮) and Details (⋮). | — Happy Path — • AC-1: Given delete = team on mobile, when the user views a contact within scope, then the Delete option in (⋮) is shown and the action succeeds.• AC-2: Given delete = disabled, when the user views any contact on mobile, then Delete is hidden from both index (⋮) and details (⋮), and delete via API returns 403.— Permission Model — • CAN: delete = team or everything, within scope.• CANNOT: delete = disabled; team out-of-scope.• Unauthorized: Delete hidden; API → 403. — UI States — • Loading: confirmation modal. • Error: "Could not delete." • Success: contact removed. |
[TEAM-S08] — Enforce customers_customers_searchassoc (Website)As a System, I want to enforce customers_customers_searchassoc on web, so that contact-association search results are correctly scoped when creating/editing Deals, Tasks, Tickets, and Companies. | Must Have | Figma: N/A — server-side enforcement | Data Fields: customers_customers_searchassoc level; contact team_owner_ids.Before-After Behavior: Before: web supports OWNED ONLY + ALL ACCESS across Deal/Task/Ticket/Company. After: TEAM ONLY added — association search returns only team-scoped contacts. | — Happy Path (applies to Deal, Task, Ticket, Company) — • AC-1: Given searchassoc = team, when the user searches for a customer association (Create Deal, Edit Task, Create Ticket, Edit Company), then results return only contacts where team_owner_ids intersects the user's teams or descendant teams, OR the contact is Unassigned, OR owner_id/assignee_id == user.• AC-2: Given searchassoc = disabled, when the user opens a create/edit form with a customer-association field, then the association search component is hidden and the "contact association" filter is removed from the module index.• AC-3: Given searchassoc = disabled, when an attempt is made via OpenAPI to associate a contact, then 403 is returned.— Permission Model — • CAN: searchassoc = team or everything, within scope.• CANNOT: searchassoc = disabled.• Unauthorized: Association search component hidden; API → 403. — UI States — • Loading: typeahead spinner. • Empty: "No contacts found in your team." • Error: "Could not load contacts." • Success: filtered results. |
[TEAM-S09] — Enforce customers_customers_searchassoc (CRM Mobile App)As a System, I want to enforce customers_customers_searchassoc on CRM Mobile, so that association results are scoped identically to web. | Should Have | Figma: N/A — server-side enforcement | Data Fields: same as TEAM-S08. Before-After Behavior: Before: mobile supports OWNED ONLY + ALL ACCESS. After: TEAM ONLY added — association search in Deal/Task/Ticket contexts returns only team-scoped contacts. | — Happy Path — • AC-1: Given searchassoc = team on mobile, when the user searches for a customer association in any module, then only team-scoped contacts are returned — same server-side filter as web.• AC-2: Given searchassoc = disabled, when the user opens a create/edit form with customer association, then the association search component is hidden.— Permission Model — • CAN: searchassoc = team or everything, within scope.• CANNOT: searchassoc = disabled.• Unauthorized: Component hidden; API → 403. — UI States — • Loading / Empty / Error / Success. |
| [TEAM-S10] — Set Team Owner on a contact (Website) — MULTI-SELECT As a CS Agent, I want to assign one or more Team Owners (or clear them) on a customer from the detail page, so that my teams' ownership is formally recorded and contacts can be co-owned and handed off collaboratively. | Must Have | Figma: Team Owner multi-select (MpInputTag) — detail / create / edit | Data Fields: • team_owner_ids (array of UUIDs, may be empty) — the user's teams from the teams-for-user API; auto-fill = ALL of the creator's teamsBefore-After Behavior: Before: no Team Owner field exists on the customer detail page. After: a "Team's Owner" multi-select appears; agent adds/removes any of their assigned teams or clears all to Unassigned. | — Happy Path — • AC-1: Given the agent clicks the Team's Owner field, when the multi-select opens, then a flat "My Teams" list is shown ( MpInputTag suggestions) with current value(s) pre-selected as chips and the ability to remove any chip.• AC-2: Given a new contact is being created, when the form renders, then team_owner_ids auto-fills to ALL of the creator's teams; creator with no team → empty array (Unassigned).• AC-3: Given the agent selects one or more teams and saves, when PATCH /cdp/customers/{id} is called with team_owner_ids, then the record is updated and the field displays team-name chips.• AC-4: Given the agent removes all teams and saves, when submitted with team_owner_ids = [], then the field saves as an empty array and displays "Unassigned."• AC-5: Given the agent submits a team_owner_ids array containing any team that is not one of their assigned teams, when the server validates, then the request is rejected with 422 and the field reverts.• AC-6: Given a contact has no Team Owner set, when the agent views the detail page, then the field shows "Unassigned" (not blank). — Error / Unhappy Path — • ERR-1: Given the teams-for-user API is unavailable, when the multi-select opens, then it shows "Could not load teams. Try again." and saving Team Owner is blocked until teams load. • ERR-2: Given the agent saves Team Owner, when PATCH returns 5xx, then the field reverts and a toast shows "Could not save Team Owner. Try again."— Permission Model — • CAN: Agents/supervisors with customers_customers_manage ≠ disabled on contacts within their scope.• CANNOT: manage = disabled; users attempting to add a team they don't belong to.• Unauthorized: Team Owner field is read-only for users without manage permission. — UI States — • Loading: spinner. • Empty: "No teams assigned" + Unassigned state. • Error: "Could not load teams." • Success: team-name chips displayed. |
| [TEAM-S11] — Set Team Owner on a contact (CRM Mobile App) — MULTI-SELECT As a CS Agent on Qontak One Mobile, I want to set or clear one or more Team Owners on a contact from my phone, so that team assignments don't require switching to desktop. | Should Have | Figma: Team Owner multi-select (mobile) — detail / create | Data Fields: same as TEAM-S10 (team_owner_ids array; auto-fill = ALL of the creator's teams).Before-After Behavior: Before: no Team Owner field on the mobile contact detail screen. After: a Team Owner multi-select appears; agent selects one or more teams from a flat searchable list or clears all to Unassigned. | — Happy Path — • AC-1: Given the agent opens the Team's Owner field on mobile, when the picker opens, then a flat searchable multi-select of their assigned teams is shown with current values pre-selected as chips. • AC-2 / AC-3 / AC-4 / AC-5 / AC-6: Same auto-fill-all-teams, save, clear-to-Unassigned, per-element membership validation, and "Unassigned" display rules as TEAM-S10. • AC-7: Given the mobile device is offline, when the agent opens the Team Owner picker, then it shows "Team list unavailable — check your connection." — Permission Model — Same as TEAM-S10. — UI States — • Loading: spinner. • Empty: Unassigned state. • Error: offline / API error. • Success: team-name chips shown. |
[TEAM-S12] — Enforce customers_customernotes_view (Website)As a System, I want to enforce customers_customernotes_view on web, so that note visibility follows the parent contact's team scope. | Must Have | Figma: N/A — server-side enforcement | Data Fields: customers_customernotes_view level; parent contact.team_owner_ids; viewer team IDs.Before-After Behavior: Before: a contact's notes are visible to any company user who passes the binary view gate — no team scoping. After: TEAM ONLY added — a user sees a contact's notes only if they can see the parent contact under team scope. | — Happy Path — • AC-1: Given customernotes_view = team and the user opens a contact whose team_owner_ids intersects their team IDs (or they own/are assigned it), when the Notes tab loads, then the contact's notes are returned.• AC-2: Given customernotes_view = team and the user opens (by direct URL/API) a contact outside their team scope, when they request its notes, then 403 is returned — notes inherit the parent contact's scope.• AC-3: Given customernotes_view = everything, when they load any contact's notes, then all notes on that contact are returned.• AC-4: Given customernotes_view = own, when they load notes on a contact they neither own nor are assigned, then 403 is returned.— Error / Unhappy Path — • ERR-1: Given view = team, when the teams-for-user API is unavailable, then the notes request falls back to the same OWNED-ONLY rule as CHG-002 and logs cdp_team_permission_fallback.— Permission Model — • CAN: Users with customernotes_view ≠ disabled, for notes on in-scope contacts.• CANNOT: disabled; team users for notes on out-of-scope contacts.• Unauthorized: Notes tab not populated; direct notes API → 403. — UI States — • Loading: notes skeleton. • Empty: "No notes yet." • Error: "Could not load notes." • Success: notes list. |
[TEAM-S13] — Enforce customers_customernotes_view (CRM Mobile App)As a System, I want to enforce customers_customernotes_view on mobile, so that note visibility matches web for the same role. | Should Have | Figma: N/A — server-side enforcement | Data Fields: same as TEAM-S12. Before-After Behavior: Before: CDP notes (behind flag_contact_360 + flag_note_cdp) shown without team scoping. After: TEAM ONLY added — same server-side parent-contact scope as web. | — Happy Path — • AC-1: Given customernotes_view = team on mobile, when the user opens a contact's Notes section, then notes are returned only if the parent contact is in their team scope — identical server-side rule to web.• AC-2: Given the parent contact is out of team scope, when the mobile app requests its notes, then 403 is returned. — Permission Model — Same as TEAM-S12. — UI States — • Loading: skeleton. • Empty: "No notes yet." • Error: inline error. • Success: notes list. |
[TEAM-S14] — Enforce customers_customernotes_manage (Website)As a System, I want to enforce customers_customernotes_manage on web, so that note editing follows team scope. | Must Have | Figma: N/A — server-side enforcement (per-note permission.update flag) | Data Fields: customers_customernotes_manage level; parent contact.team_owner_ids; per-note permission.update.Before-After Behavior: Before: note edit is owner-only regardless of permission level. After: TEAM ONLY added — a team/everything user can edit notes on in-scope contacts; the API computes per-note permission.update. | — Happy Path — • AC-1: Given customernotes_manage = team and a note on a contact in the user's team scope, when the API returns the note, then note.permission.update = true and the Edit action is available (NotesList.vue reads this flag).• AC-2: Given manage = team and a note on a contact outside team scope, when they attempt to edit via the API, then 403 is returned and note.permission.update = false.• AC-3: Given manage = own, when the user edits a note on a contact they don't own/aren't assigned, then 403 is returned.— Error / Unhappy Path — • ERR-1: Given manage = disabled, when viewing any note, then edit is hidden and edit via API → 403.— Permission Model — • CAN: manage = team/everything on in-scope contacts (broadened from owner-only — see OQ-11).• CANNOT: disabled; team on out-of-scope contacts.• Unauthorized: Edit hidden (server sets note.permission.update = false); API → 403.— UI States — • Loading: editor. • Error: "Could not save note." • Success: note updated. |
[TEAM-S15] — Enforce customers_customernotes_manage (CRM Mobile App)As a System, I want to enforce customers_customernotes_manage on mobile, so that note editing matches web. | Should Have | Figma: N/A — server-side enforcement | Data Fields: same as TEAM-S14. Before-After Behavior: Before: edit gated by the contact-level permission.update flag; per-note permission parsed but ignored. After: TEAM ONLY added — mobile reads each note's note.permission.update (computed by API) instead of the contact-level flag. | — Happy Path — • AC-1: Given manage = team on mobile and a note on an in-scope contact, when the note list/detail renders, then the Edit/Save action is gated by note.permission.update (not the contact-level hasEditPermission).• AC-2: Given a note on an out-of-scope contact, when the user attempts to edit via the API, then 403 is returned. — Permission Model — Same as TEAM-S14. — UI States — • Loading: editor. • Error: "Could not save." • Success: note updated. |
[TEAM-S16] — Enforce customers_customernotes_delete (Website)As a System, I want to enforce customers_customernotes_delete on web, so that note deletion follows team scope. | Must Have | Figma: N/A — server-side enforcement (per-note permission.delete flag) | Data Fields: customers_customernotes_delete level; parent contact.team_owner_ids; per-note permission.delete.Before-After Behavior: Before: note delete is owner-only regardless of level. After: TEAM ONLY added — team/everything users can delete notes on in-scope contacts; API computes per-note permission.delete. | — Happy Path — • AC-1: Given customernotes_delete = team and a note on an in-scope contact, when the API returns the note, then note.permission.delete = true and the Delete action is available.• AC-2: Given delete = team and a note on an out-of-scope contact, when they attempt to delete via the API, then 403 is returned and note.permission.delete = false.• AC-3: Given delete = disabled, when viewing any note, then Delete is hidden and delete via API → 403.— Permission Model — • CAN: delete = team/everything on in-scope contacts.• CANNOT: disabled; team on out-of-scope contacts.• Unauthorized: Delete hidden; API → 403. — UI States — • Loading: confirmation modal. • Error: "Could not delete note." • Success: note removed. |
[TEAM-S17] — Enforce customers_customernotes_delete (CRM Mobile App)As a System, I want to enforce customers_customernotes_delete on mobile, so that note deletion matches web. | Should Have | Figma: N/A — server-side enforcement | Data Fields: same as TEAM-S16. Before-After Behavior: Before: delete gated by contact-level flag; per-note permission ignored. After: TEAM ONLY added — mobile reads note.permission.delete. | — Happy Path — • AC-1: Given delete = team on mobile and a note on an in-scope contact, when the note renders, then the Delete action is gated by note.permission.delete.• AC-2: Given a note on an out-of-scope contact, when the user attempts to delete via the API, then 403 is returned. — Permission Model — Same as TEAM-S16. — UI States — • Loading: confirmation modal. • Error: "Could not delete." • Success: note removed. |
| [TEAM-S18] — Team Owner auto-syncs to the new owner's teams when contact ownership changes As a System, when a contact's owner_id is reassigned to a different user, I want team_owner_ids to automatically re-derive to the new owner's teams, so that team ownership stays consistent with who owns the contact (web + mobile + API). | Must Have | Figma: N/A — server-side derivation (Team Owner chips re-render with the new value) | Data Fields: • owner_id (the contact's owner being changed)• team_owner_ids (re-derived = ALL teams of the new owner, from the teams-for-user API)Before-After Behavior: Before: team_owner_ids was set at create (auto-fill = creator's teams) and only changed by manual edit — an owner change left the old team owners in place. After: changing the owner re-derives team_owner_ids to the new owner's full team set (mirrors the create-time auto-fill, D-4); the previous value is replaced. | — Happy Path — • AC-1: Given customer X has owner_id = Zhelia and team_owner_ids = {Product Management, Qontak Product} (Zhelia ∈ {Product Management, Qontak Product}), when Zhelia changes the owner of X to Rara (Rara ∈ {Product Management, Qontak Designer}), then team_owner_ids is automatically set to {Product Management, Qontak Designer} (Rara's teams) — replacing the prior value.• AC-2: Given an owner change to a user who belongs to no team, when the owner is saved, then team_owner_ids becomes empty (Unassigned).• AC-3: Given the owner is changed and then changed again, when each save commits, then team_owner_ids always reflects the current owner's teams.• AC-4: Given the re-derivation completes, then cdp_team_owner_set is emitted with source = owner_change (distinct from manual edit / migration).• AC-5 (scope-shift side effect): Given X's team_owner_ids changes from {Product Management, Qontak Product} to {Product Management, Qontak Designer}, when a Qontak-Product-only member (not owner/assignee) next loads the list under TEAM ONLY, then X no longer appears for them; Qontak-Designer members now see it.— Error / Unhappy Path — • ERR-1: Given the owner change is saved, when the teams-for-user lookup for the new owner is unavailable (timeout/5xx), then the owner change still commits but the team_owner_ids re-derivation is retried async (it is not silently left as the prior owner's teams); cdp_team_permission_fallback is logged with source = owner_change. (Retry-vs-defer policy — OQ-2.)— Permission Model — • CAN: any user permitted to change the contact's owner ( customers_customers_manage within scope). The team-owner re-derivation is system-driven, not a manual field edit.• CANNOT: a manual team_owner_ids value does not survive an owner change — the owner-change derivation takes precedence (OQ-2 edge).— UI States — • Loading: owner-field saving spinner. • Success: Team Owner chips update to the new owner's teams. • Error: owner saved; Team Owner shows the prior value until re-derivation completes/retries. |
| [TEAM-S19-NEG] — TEAM ONLY guard rails (no leakage / correct hierarchy + Unassigned) (Guard Rail — from Non-Goals) As the System, I must not leak cross-team contacts, must traverse hierarchy downward only, and must treat Unassigned as visible-to-all. | Guard Rail | — | — | • NEG-1: Given Zhelia ∈ {Product Management, Qontak Product}, when she creates a contact, then team_owner_ids auto-fills to both teams; and on the TEAM ONLY list she sees every contact whose team_owner_ids includes either team or any descendant team, plus Unassigned, plus her own owned/assigned — contacts owned only by unrelated teams do not appear (web + mobile).• NEG-2: Given view = team and the user belongs to no team, when they load the list, then they see Unassigned + their own owned/assigned records only (no other-team leakage); direct URL to a team-owned contact they don't qualify for → 403.• NEG-3: Given a TEAM ONLY user whose teams (and sub-teams) own no contacts and there are no Unassigned contacts, when they load the list, then "No contacts in your team yet." is shown — not an error. • NEG-4: Given a user is removed from a team in Launchpad, when they next load the list (real-time resolution, no long-term cache), then they no longer see that team's contacts. • NEG-5: Given view = own and a user opens a teammate's contact by direct URL, then 403 — TEAM ONLY is not implied by OWNED ONLY.• NEG-6: Given manage = team and a team_owner_ids array including a team they don't belong to via OpenAPI, then 422 (per-element validation).• NEG-7: Given a note on a contact owned only by a team Zhelia does NOT belong to, when Zhelia ( customernotes_view = team) opens that contact's notes by direct URL/API, then 403 — notes never widen their parent contact's scope (web + mobile).• NEG-8 (hierarchy): Given a manager in a parent team and a contact owned only by a descendant team, when the manager loads under view = team, then the contact appears (downward traversal); but a user in only the child team does not see a parent-team-owned contact (no upward traversal) unless owner/assignee.• NEG-9 (Unassigned): Given a contact has empty team_owner_ids, when any TEAM ONLY user (including teamless) loads the list, then the contact appears — Unassigned is visible to all (D-13). |
Dependencies: TEAM-S01 → CHG-001 (Launchpad settings UI), applied to all in-scope CDP customer + notes permission keys; TEAM-S02–S09 → CHG-002 (enforcement) + the teams-for-user(+descendants) API; TEAM-S10–S11 → CHG-003 (Team Owner field) + per-element validation; TEAM-S12–S17 → CHG-006 (notes scope) + the extended EvaluatePermissions (D-10); TEAM-S18 (owner-change auto-sync) → CHG-003 persistence rule + the teams-for-user API (re-derive the new owner's teams). Mobile stories (S03/S05/S07/S09/S11/S13/S15/S17) reuse the same server-side filter as their web counterparts.
11. Rollout
| Field | Detail |
|---|---|
| Flag | cdp_team_permission_enabled | default: OFF — dedicated per-company flag (not GetIsPermissionUsmanEnabled). |
| Stage 0 | Schema + multikey index added (additive, no behaviour change). team_owner_ids written on new records; reads ignore it. |
| Stage 1 | Migration of existing contacts (CHG-004) on internal QA tenant; verify coverage and Unassigned counts. Migrated records carry multi-element arrays — split from the legacy crm_team_hierarchy_id CSV (see CHG-004). |
| Stage 2 | Enable TEAM ONLY enforcement behind the flag on QA, then beta tenants. Monitor denial/fallback logs. |
| GA | Progressive per-tenant enable for Growth + Enterprise. |
| Migration owner | Decision needed (OQ-1): CDP backfill job vs Bifrost sync (Postgres crm_people.crm_team_hierarchy_id CSV → split(',') → name-map → Mongo team_owner_ids multi-element array). |
11.1 Migration Transition Window
During Stage 1–2, migrated and not-yet-migrated contacts coexist. A contact not yet carrying team_owner_ids is treated as Unassigned (empty) → visible to all Team Only users (no contact is hidden by migration lag). Enforcement (Stage 2) is only enabled per tenant after the CHG-004 coverage report confirms ≥95% coverage; until then the tenant stays on its current level behaviour behind the OFF flag.
12. Observability
Events (emitted by contact-service during TEAM ONLY enforcement):
| Event | Trigger | Properties | Alert |
|---|---|---|---|
cdp_team_permission_fallback | Launchpad teams-for-user API times out (>500ms) or returns 5xx → request falls back to OWNED ONLY | company_sso_id, user_sso_id, platform (web/mobile), permission_name, latency_ms | > 1% of team-level requests in any 5-min window → page CDP on-call |
cdp_team_permission_denied | Single-get / single-record action where viewerTeamIDs ∩ contact.team_owner_ids = ∅ and user is not owner/assignee → 403 | company_sso_id, user_sso_id, contact_id, permission_name, viewer_team_count | Spike > 3× 7-day baseline → notify CDP Squad (possible mis-scoped role) |
cdp_team_owner_validation_rejected | Write rejected (422) because a submitted team_owner_ids element is not one of the user's teams | company_sso_id, user_sso_id, rejected_team_ids | — (info only; informs UX) |
cdp_team_owner_set | team_owner_ids successfully written on create/edit or auto-re-derived on owner change (TEAM-S18) | company_sso_id, contact_id, team_owner_count, source (web/mobile/openapi/migration/owner_change) | — |
| Field | Detail |
|---|---|
| Dashboard owner | CDP Squad (eng on-call rotation). Dashboard tracks fallback rate, denial rate, validation-rejection rate, and coverage (% contacts with non-empty team_owner_ids). |
| Monitoring cadence | Weekly review for the first month post-GA, then monthly. Investigate if fallback rate > 1% sustained over 24h, or if coverage drops below the 95% target after migration. |
13. Success Metrics
| Metric | Definition | Baseline | Target |
|---|---|---|---|
| ⭐ Adoption — TEAM ONLY configured | % of Growth+Enterprise tenants with ≥1 action set to team within 60 days | N/A | ≥ 30% of beta tenants within 30 days of GA |
| Correctness — cross-team leakage | Audited cross-team records visible under TEAM ONLY | N/A | 0 confirmed leaks |
| Performance — list latency | P95 contact-list load under TEAM ONLY | Capture everything P95 before build | ≤ 2s P95, ≤ 20% over baseline |
| Reliability — permission errors | % permission-check calls returning 5xx under TEAM ONLY | N/A | ≤ 0.5% within 30 days of GA |
| Data quality — Team Owner coverage | % contacts with non-empty team_owner_ids post-migration | 0% | ≥ 95% |
14. Launch Plan & Stage Gates
| Stage | Audience | Duration | Success Gate | Owner |
|---|---|---|---|---|
| Stage 0 — Schema (additive) | Engineering only | 1 sprint | team_owner_ids field + compound multikey index (company_sso_id, is_deleted, team_owner_ids) live (migration 032); new records write the field; reads ignore it; zero behaviour change for existing levels. | CDP Eng |
| Stage 1 — Migration (QA tenant) | Internal QA tenant | 1 week | CHG-004 backfill run; coverage report shows ≥95% contacts with non-empty team_owner_ids; Unassigned + unmatched-team counts reviewed; multi-element arrays verified from crm_team_hierarchy_id CSV. | CDP Eng + Platform |
| Stage 2 — Enforcement (QA → beta) | QA tenant, then 3–5 beta tenants (Growth/Enterprise) | 2 weeks | TEAM ONLY enforced behind cdp_team_permission_enabled; 0 confirmed cross-team leaks in audit; fallback rate < 1%; P95 list latency ≤ 2s; existing team-level roles audited + comms sent before enable. | PM + CDP Eng |
| GA | Progressive per-tenant (Growth + Enterprise) | Ongoing | Per-tenant enable only after that tenant's migration coverage ≥95% and audit clean; permission-error rate ≤ 0.5%. | PM + Ops |
15. Dependencies
| Dependency | Owner | Deliverable | Blocking? |
|---|---|---|---|
| "Teams for a user (+ descendants)" API (NEW — does not exist today) | Launchpad | New endpoint (e.g. GET /iag/v1/users/me/teams) returning the teams the user belongs to and their descendant (child) team IDs (hierarchy traversal): [{id, name, parent_id}] or an expanded_team_ids set. Net-new query (ListTeamsByUserID) joined with hierarchy expansion — Launchpad already has teams.parent_id (schema.sql:180-187) and traversal queries (ListTeamHierarchy, TraverseTeamLeaf, TraverseTeamRoot, TeamIsParent), so descendants can be resolved server-side. Must return empty array (not error) for teamless users. Decision (OQ-3a): expand descendants in Launchpad vs in contact-service. | YES |
| CHG-001 Role & Permission settings UI (Launchpad-owned — NEW) | Launchpad Squad (or Admin Portal Squad — confirm) | Expose team as a selectable radio option for the four CDP customer permission actions. The level-update endpoint is PUT /users/{sso_id}/permissions/{permission_name}/level (Launchpad rest_router.go:172) — not customer-fe. Confirm the exact settings UI codebase. | YES |
crs_permissions provides the per-action level | Launchpad | Already exists; carries no team data. | YES |
| Legacy → new team name-mapping reference | Launchpad + CDP | Crosswalk from legacy team integer IDs (from the crm_team_hierarchy_id CSV) to Launchpad team UUIDs by team name within company (see CHG-004). | YES (migration) |
team_owner_ids field + multikey index + 4-file schema change | CDP Backend | See §7 Constraints for the 4 files. Compound multikey index (company_sso_id, is_deleted, team_owner_ids). | YES |
| Qontak One Mobile — CDP module | Mobile Squad | Same server-side filter + render Team Owner multi-select field. | YES |
is_primary) | No longer required. Auto-fill now selects ALL of the user's teams, so there is no need to disambiguate a single primary team. (Was a v2.2 future dependency; dropped in v2.3.) | No |
📊 Dependency Graph
graph LR
F[Team Owner + TEAM ONLY] -->|BLOCKING| LPT[Launchpad teams-for-user +descendants API]
F -->|BLOCKING| CFG[Launchpad Role and Permission settings UI - team radio]
F -->|BLOCKING| SCH[contact-service: team_owner_ids + multikey index + 4-file change]
F -->|BLOCKING - migration| MAP[Legacy team ID to Launchpad UUID name-map]
F -->|BLOCKING| MOB[mobile-qontak-crm: filter + Team Owner field]
F -.->|non-blocking| CRS[crs_permissions per-action level - exists]
16. Key Decisions + Alternatives Rejected
16a — Decisions Made
Decisions re-grounded 2026-06-12 (multi-select change); earlier decisions dated 2026-06-03.
| ID | Decision | Rationale (grounded) |
|---|---|---|
| D-1 | Filter on the stored team_owner_ids, not on live membership resolution. | Mirrors proven legacy CRM design (search_parameter.rb:2453-2467). Single indexed multikey $in; stable when a team's members change. |
| D-2 | TEAM ONLY = team_owner_ids empty (Unassigned → visible to all) OR team_owner_ids ∩ ({viewer's teams} ∪ {descendant teams}) ≠ ∅ OR viewer is owner_id/assignee_id. | v2.5. Mirrors legacy search_parameter.rb:2453-2468 exactly: crm_team_ids << nil (empty → visible to all) + crm_teams.map(&:descendant_ids) (downward hierarchy). Multi-team many-to-many in Launchpad (models.go:191) + hierarchy via teams.parent_id. OR owner/assignee == self ensures a user always sees their own records. |
| D-3 | Team Owner is an editable, MULTI-SELECT field (team_owner_ids array); can be emptied to Unassigned. | v2.3 change. A contact may be collaboratively owned by several teams (e.g. Sales + CS). Rendered with MpInputTag (the Pixel multi-tag component), forking the existing MultipleSelect.vue. |
| D-4 | Auto-fill = ALL of the creator's teams (every team the user belongs to). Creator with no team → empty array (Unassigned). | v2.3 change. Matches the requested behaviour: "Zhelia ∈ {PM Qontak, Product Management} → both auto-fill." Removes the v2.2 earliest-created_at/tie-break rule entirely — taking all teams needs no ordering or primary-team disambiguation. The same derivation also runs when a contact's owner_id changes — team_owner_ids re-syncs to the new owner's full team set (TEAM-S18). |
| D-5 | Creator with no team → Team Owner = empty array (Unassigned). | Teamless users are real: TeamIDs not required (user_request.go:20), SSO-invite/migrate flows assign no team. |
| D-6 | Migrate from crm_people.crm_team_hierarchy_id (the user-editable multi-team owner list — a CSV of team IDs) → team_owner_ids array via split(','). | Corrected (v2.5). Production grounding proved crm_team_hierarchy_id is the actual editable multi-team "Team's Owner" (form edit.html.erb:1202 multiple: true; permitted as array leads_controller.rb:1844; written CSV "3,7" at leads_controller.rb:1261-1263; read via crm_team_hierarchy person.rb:234-245). It maps directly to the new team_owner_ids array (multi-element, no longer single). The scalar crm_team_id is only the creator's auto-stamped home team (set_crm_team_id) — NOT the owner field; do not migrate from it. |
| D-7 | Hierarchy traversal IS supported (downward). A viewer sees records owned by their teams and descendant (child) teams; a child-team member does not see parent-team records. | Reversed in v2.5. Grounded in legacy descendant_ids traversal (search_parameter.rb:2465) + Launchpad hierarchy (teams.parent_id schema.sql:180-187; ListTeamHierarchy/TraverseTeamLeaf). Resolves OQ-3. |
| D-13 | Empty team_owner_ids (Unassigned) is visible to ALL Team Only users. | v2.5. Matches legacy crm_team_ids << nil (search_parameter.rb:2463) — unassigned records are not orphaned/hidden. Resolves OQ-4 (chosen: visible to all). |
| D-8 | Use dedicated flag cdp_team_permission_enabled, not GetIsPermissionUsmanEnabled. | GetIsPermissionUsmanEnabled is company-wide (all 12 permissions, require_permission_middleware.go:21); reusing it changes behavior for tenants with it already ON. |
| D-9 | CHG-001 is Launchpad-owned, not customer-fe. | Level-update endpoint is PUT /users/{sso_id}/permissions/{permission_name}/level in Launchpad (rest_router.go:172). No permission settings UI exists in customer-fe. |
| D-10 | EvaluatePermissions signature must be extended to accept teamOwnerIDs []string and viewerTeamIDs []string. | Current signature (permission_service.go:41-46) has no team params. Per-record TEAM ONLY evaluation (single-get guard) requires the record's owning teams and the viewer's teams to compute array intersection. BREAKING CHANGE affecting all callers. |
| D-11 | Multi-select options = the user's OWN teams (from the new teams-for-user API), not all company teams. | The existing FE getTeams() hits GET /v1/teams (all company teams, capped per_page=10) — wrong source. A user may only assign teams they belong to (validation rule), so the picker must be sourced from the user's teams. See CHG-007 (FE-1). |
16b — Alternatives Rejected
Rejections dated 2026-06-03, with v2.3 additions 2026-06-12.
| Alternative | Why Rejected |
|---|---|
| Single-select Team Owner (v2.2 model) | Cannot express collaborative cross-team ownership. Requested behaviour is multi-team. Replaced by multi-select team_owner_ids array (D-3). |
| Auto-fill earliest team / by "Primary Team" | Superseded: auto-fill now takes ALL teams (D-4), so neither created_at ordering nor an is_primary flag is needed. No is_primary exists in Launchpad team_users anyway. |
| Filter by live owner/assignee/created_by team membership | 1+N Launchpad calls per request; volatile key; contacts vanish when membership changes. Contradicts proven legacy design. |
| Hierarchical/ancestor picker | Ancestors ≠ user's teams. Real UI scopes to flat team memberships. |
| Read-only auto-derived Team Owner (legacy model) | Production UI is editable; users pick among their teams. |
Source picker from getTeams() (all company teams) | Returns all company teams capped at per_page=10; not the user's teams. A user can only assign their own teams. Use the new teams-for-user API (D-11). |
| Client-side filtering | Bypassable; not a security boundary. |
Reuse GetIsPermissionUsmanEnabled | Company-wide; would affect all 12 permissions for tenants with it already ON. |
crm_team_id (single → single-element array) | Reversed in v2.5. crm_team_id is only the creator's auto-stamped home team, not the owner field. The real multi-team owner is crm_team_hierarchy_id (CSV) — see D-6. |
17. Open Questions
| # | Type | Question | Owner | Deadline |
|---|---|---|---|---|
| OQ-1 | Decision | Migration ownership & mechanism: CDP backfill job vs Bifrost sync? (Maps crm_team_hierarchy_id CSV → multi-element team_owner_ids — see CHG-004/D-6.) | CDP Eng + Platform | 2026-06-17 |
| OQ-2 | Decision | Auto-fill freshness: team_owner_ids now re-derives to the new owner's teams on owner change (TEAM-S18 — resolved). Still open: (a) should it also re-sync when the current owner later joins/leaves teams without an owner change? (Proposed: no — snapshot otherwise.) (b) On owner change, if the new owner's teams-for-user lookup fails — retry async vs defer? (Proposed: retry async; never silently keep the prior owner's teams.) | PM + CDP Eng | 2026-06-17 |
| OQ-3 | ✅ Resolved (v2.5) | TEAM ONLY does traverse team hierarchy (downward — viewer sees descendant/child-team records). See D-7. | PM | Resolved |
| OQ-3a | Decision | Resolve descendant team IDs in Launchpad (return expanded set from the teams-for-user API) or in contact-service (call hierarchy + expand)? Affects caching + API contract. | CDP Eng + Launchpad | 2026-06-17 |
| OQ-4 | ✅ Resolved (v2.5) | Empty team_owner_ids (Unassigned) → visible to all Team Only users (legacy-aligned via crm_team_ids << nil). See D-13. | PM | Resolved |
| OQ-5 | Decision | Source of truth: CDP customers_customers_view vs legacy crm_view? Same contact in both systems may yield different visibility. | PM + Architecture | 2026-06-17 |
| OQ-6 | Scope | (Partially resolved v2.4) customers_customernotes_* are now IN scope (CHG-006, Stories 12–17). Still open: do customers_customers_profileview / profilemanage need TEAM ONLY? | PM | 2026-06-17 |
| OQ-7 | Behaviour | Can a TEAM ONLY user with manage add a team_owner_id for a team they don't belong to? (Proposed: no — every element must be one of the user's teams.) | PM | 2026-06-17 |
| OQ-8 | Decision | Should cdp_team_permission_enabled be a single per-tenant boolean, or per-permission-key (so view can be staged before manage)? | CDP Eng + PM | 2026-06-17 |
| OQ-9 | Behaviour | Is there a max cap on the number of team owners per contact? (Proposed: cap at a sane limit, e.g. the user's team count; no hard product cap in v1.) | PM | 2026-06-17 |
| OQ-10 | Scope | Note creation (customers_customernotes_add) is governed separately from view/manage/delete. Does it need explicit TEAM ONLY, or does the parent-contact visibility gate suffice (you cannot open a contact you can't see)? (Proposed: parent-contact gate suffices.) | PM + CDP Eng | 2026-06-17 |
| OQ-11 | Behaviour | Note update/delete are owner-only today regardless of level. Under team/everything, should a team/everything user be able to edit/delete others' notes on an in-scope contact? (Proposed: yes — consistent with contact-level scoping.) | PM | 2026-06-17 |
Appendix A — Grounded Code References
A. contact-service (Go, MongoDB)
- Permission levels
disabled/own/team/everything—internal/pkg/consts/const.go:40-42. teamcurrently = full access (return true, no scoping) —internal/app/service/permission_service.go:86-89.EvaluatePermissionscurrent signature (needsteamOwnerIDs []string+viewerTeamIDs []string) —permission_service.go:41-46.- 6 handler branches check only
OwnerPermissionKey—contact_handler.go:~256,466,543,648,760,956. - Single-get guard checks company only —
internal/app/service/get_contact.go:22-26. GetIsPermissionUsmanEnabledis company-wide —internal/pkg/middleware/require_permission_middleware.go:21.team_owner*absent repo-wide (net-new field). Array-field precedent for declaration:Tags []string \bson:"tags,omitempty"`—contact/base.go:75`.- Multikey
$inprecedent (array field intersects a list):segmented_filter_request.go:211,field_properties_search.go:66. Not yet present insearch_contact_request.go— itsToFilters()(lines 119-248) explodes array fields into$orequality (lines 138-157), so the newteam_owner_idsDTO field needsbson:"-"plus an explicit$inbranch. - Multikey index needs no special syntax — declare like a scalar index; Mongo makes it multikey automatically (precedent:
phone/name_search/name_tokenizedarrays indexed plainly in011,028). A compound index may contain at most one array field (company_sso_id,is_deletedare scalar — OK). Next migration number: 032. - Notes permission keys:
customers_customernotes_add/view/manage/delete—const.go:34-37.ContactNotehas onlycontact_id,owner_id,note,company_sso_id(noteam_owner_ids) —contact_notes/base.go:26-36; notes scope via the parent contact. - Notes routes:
rest_router.go:150-158(canonical/contacts/{contact_id}/notes) + deprecated duplicate161-168— both share handlers. Middleware (require_permission_middleware.go:78-98) is a binary gate only; stores the level but enforces no own/team/everything scoping. - Notes enforcement gap: handler does NOT call
EvaluatePermissions;resolveNotePermission(contact_notes_handler.go:143-166) sets cosmetic per-noteUpdate/Deleteflags honoring onlyeverything/own—teamis dropped.contact_notes_service.golist/get filter is company+contact only (no team); update/delete are owner-only.ValidateContactExists(contact_notes/read.go) returns a bool — no scope fields, so a new parent-contact projection is required.
B. qontak-customer-fe (Nuxt 3)
- Correction (v2.3):
getTeams()is NOT dead code — it is called infeatures/customers/detail/views/DetailPage.vue:139(onMounted). It hitsGET /v1/teams?order_by=name&order_direction=asc&page=1&per_page=10(IAG_LAUNCHPAD_URL) → all company teams, capped at 10 — wrong source for "the user's teams."UserStore.ts:62-81. - Multi-select component:
MpInputTag(from@mekari/pixel3).MpSelectis single-value (nomultipleprop). Real usage:features/customers/views/components/segment/FilterMultiselect.vue:3-13. - Strongest fork target:
common/components/field/MultipleSelect.vue(existing multi-select onMpInputTag, already reads a store). Note: it emits selected names (:147-148), not IDs — the Team Owner fork must emititem.id. - Correction (v2.3): active detail form is
features/customers/detail/components/CustomerDetails.vue(dynamic component map, field block ~106-127), not the legacyProfile.vue.multiple_selectfield_type already supported in the form pipeline (CustomerDetails.vue:835-836,868). - Contact type: add
team_owner_ids?: string[]afterassignee_name(CustomerStore.ts:304). - FE reads
perm?.is_enabledonly, neverperm?.level—ListPage.vue:531-532;DetailPage.vue:130-131. filterByRoutedrives list scope by URL path —ListPage.vue:190,367; no/customers/teamsroute exists.- Notes (web): components in
features/customers/detail/components/Notes/(Notes.vue, NoteInput.vue, NotesList.vue). Onlycustomers_customernotes_add/_viewconstants exist (Notes/constants.ts:27-28) — no_manage/_delete.canViewNotes(CustomerActivityV2.vue:56-70) andcanAddNotes(Notes.vue:83-97) readis_enabledonly (same gap as CHG-007 FE-3). Per-note edit/delete already render server-computednote.permission.update/.delete(NotesList.vue:91-101) — the backend-authorized enforcement seam. Notes APIs:GET /v1/contacts/{id}/notes,POST /v1/contacts/notes/{id},PUT/DELETE /v1/contacts/notes/{id}/{noteId}.
C. qontak.com legacy CRM (Rails) — reference model (corrected v2.5)
crm_team_hierarchy_idis the real "Team's Owner" — an editable MULTI-team list stored as a CSV of team IDs (e.g."3,7"), NOT an ancestor chain. Form:f.input :crm_team_hierarchy_id, multiple: true(edit.html.erb:1202, label "Team's Owner"edit.html.erb:1196/en.yml:2448); options =current_user.crm_teams(crm_referencable.rb:437-444); permitted as array (leads_controller.rb:1844); array→CSV on save (leads_controller.rb:1261-1263,:484-485); read viacrm_team_hierarchysplit (person.rb:234-245), names viacrm_team_hierarchy_names(person.rb:890-895). → This is the migration source (D-6/CHG-004).crm_team_id= single integer FK, auto-stamped from the creator's primary team on every save (before_save :set_crm_team_id→creator&.crm_team&.id,person.rb:19,734-736;User#crm_teamuser.rb:373-382). It is not the owner field and not user-editable (not inlead_params,leads_controller.rb:1849) — do not migrate from it.- "Team Only" filter (
search_parameter.rb:2453-2468): matchescrm_team_hierarchy_idagainst[nil] + user.crm_teams.ids + user.crm_teams.descendant_ids→ confirms (a) empty/nil visible to all (crm_team_ids << nil,:2463) and (b) downward hierarchy traversal (descendant_ids,:2465). This is the exact behavior v2.5 replicates (D-2/D-7/D-13).
D. qontak-launchpad (Go, Postgres)
team_usersmany-to-many, nois_primary;created_at NOT NULL DEFAULT NOW()—models.go:191-197,db/schema.sql:189-195. Unique index(user_id, team_id)—schema.sql:283.- Permission level update endpoint —
internal/server/rest_router.go:172:PUT /users/{sso_id}/permissions/{permission_name}/level. - No "teams for a user" endpoint and no
ListTeamsByUserIDquery — every path is teams→members (ListTeamMember,db/query/team.sql:128-140), never user→teams./users/me(UserInfoResponse) does not embed teams. Net-new: new query + service + handler + route. Because there is nois_primary, the join naturally returns all of the user's teams (no ordering/primary needed) — exactly what the new auto-fill requires. - Team hierarchy EXISTS —
teams.parent_id UUID(db/schema.sql:180-187) + traversal queriesListTeamHierarchy,TraverseTeamLeaf,TraverseTeamRoot,TeamIsParent. So the teams-for-user(+descendants) API (v2.5 D-7) can expand a user's teams to their descendant team IDs server-side. (Note: legacyCrm::Team has_ancestry; Launchpad usesparent_idadjacency + the above traversal queries.)
E. mobile-qontak-crm (Flutter / Dart) — notes on mobile
- Flutter, Melos monorepo, Clean Architecture + BLoC. CDP notes live in the
crm_notepackage behind feature flagsflag_contact_360+flag_note_cdp(crm_core/.../feature_flag_constant.dart:52,115); CDP path =note_cdp_*. - CDP API returns per-note
permission: {update, delete}→ parsed intoNotePermission(note_cdp_data_response.dart:43-52,note_permission.dart) — but the UI ignores it, gating add/edit/delete on the contact-levelpermission.updateflag (other_tab.dart:108;note_screen.dart:290;detail_note_screen.dart:629). customers_customernotes_*keys are ABSENT on mobile; mobile reads no permission level. Endpoints are the same contact-service/v1/contacts/{id}/notes[/{noteId}]as web (crm_note/.../endpoint.dart).- Fix (CHG-008 MB-1): read each note's
note.permission.update/.delete(already parsed) instead of the contact-level flag. If the API computes TEAM ONLY into those booleans (CHG-006), no level enum is needed on mobile.
PRD CHANGELOG
| Version | Date | By | Section | Type | Summary |
|---|---|---|---|---|---|
| 2.7 | 2026-06-30 | Story revisions — Team Only radio on all CDP actions + owner-change team-owner sync | §3 Example, §8 CHG-003, §9 API#5, §10 (TEAM-S01 + new TEAM-S18, guard rail → S19-NEG), §12 Observability, §16 D-4, §17 OQ-2 | UPDATED | TEAM-S01 refocused: every in-scope CDP permission action (4 customer + 3 notes) must expose the Team Only radio; AC-4 confirms out-of-scope permissions (profileview/profilemanage) are left untouched. Added TEAM-S18 — when a contact's owner_id changes, team_owner_ids auto-re-derives to the new owner's full team set (e.g. owner Zhelia {Product Management, Qontak Product} → Rara → team owner becomes {Product Management, Qontak Designer}); guard rail renumbered S18-NEG → S19-NEG. Threaded through CHG-003 persistence rule, D-4, API behavior #5 + Observability (source = owner_change), OQ-2 (owner-change resolved; current-owner membership drift + lookup-failure retry still open), and the §3 Example (Case 3). Also restored the Figma frame links on the TEAM-S10 (web — detail/create/edit) and TEAM-S11 (mobile — detail/create) Mockup cells. |
| 2.6 | 2026-06-30 | Reformat to canonical template | All (structure only) | REFORMATTED | Restructured to the canonical Qontak ADJUSTMENT template with no change to content meaning: sections reordered + renumbered (Feature Changes → API → User Stories → Rollout → Observability → Metrics → Launch Plan → Dependencies → Decisions → Open Questions); the 17 prose ### Story blocks + 9 negative scenarios converted to the native Section-10 story table with per-story TEAM-S0X composite IDs and strict Gherkin ACs; the "Frontend Changes" + "Mobile Changes" sections folded into Feature Changes as CHG-007 / CHG-008; added §9 API & Webhook Behavior (promoted from CHG-002), §14 Launch Plan & Stage Gates (derived from Rollout stages), a Dependency Graph, and a What Happens If We Don't Build This section. Header/version synced to 2.6. |
| 2.5 | 2026-06-16 | TEAM ONLY logic: empty-visible-to-all + hierarchy traversal; migration source corrected (grounded) | CONTEXT, S1, S3, S4, S5, S7, CHG-002, CHG-004, CHG-006, S13 (Flow A, Story 2 AC-6/7, NEG-1/2/3/8/9), D-2/6/7/13, OQ-3/3a/4, App.C/D | UPDATED | Refined TEAM ONLY to match production/legacy: (1) empty team_owner_ids (Unassigned) is visible to ALL Team Only users (D-13, resolves OQ-4); (2) hierarchy traversal — viewer also sees contacts owned by descendant (child) teams of their teams (D-7, resolves OQ-3, reverses v2.4 flat model). Filter = $or(empty OR $in {teams+descendants} OR owner/assignee). Migration source corrected from crm_team_hierarchy_id → multi-element team_owner_ids (D-6, CHG-004, Appendix C). |
| 2.4 | 2026-06-16 | Notes TEAM ONLY + mobile in scope (grounded) | CONTEXT, S3, S4, CHG-006, FE-6, S12 (mobile), S13 (Stories 12–17, NEG-7), OQ-6/10/11, App.A | UPDATED | Extends TEAM ONLY to Contact Notes (customers_customernotes_view/_manage/_delete) — notes inherit parent-contact team scope (CHG-006). Adds mobile-qontak-crm (Flutter CDP/Contact360) as a grounded platform. 6 new stories (12–17; web Must / mobile Should) + NEG-7. Reverses v2.3 Non-Goal #7 for notes (resolves OQ-6 partially); adds OQ-10 / OQ-11. |
| 2.3 | 2026-06-12 | Multi-select + all-teams change (re-grounded) + score fixes | S1, S4, S5, S6.5, S7, S8, S9, S10, S11, S13, App.A | UPDATED | Team Owner is now multi-select (team_owner_ids array) and auto-fill = ALL of the creator's teams (D-3, D-4). Filter → multikey $in array intersection. Multikey index, migration 032. Per-element validation. is_primary dependency dropped. MoSCoW Priority added to all stories; Observability section added; one-liner trimmed to ≤25 words. |
| 2.2 | 2026-06-03 | Score fixes (10 grounded gaps) | S4, S7, S8a, S8b, S10, S11, S13, S14, S15 | UPDATED | Strict Gherkin rewrite of all 11 stories; permission model block on every story; mermaid sequenceDiagram for Flow A. CHG-001 re-assigned to Launchpad; 4-file scope; 6 handler branches; EvaluatePermissions breaking change (D-10); dedicated cdp_team_permission_enabled flag (D-8 + CHG-005). |
| 2.1 | 2026-06-02 | Section 13 expansion | S13 | MODIFIED | Per-permission-key stories split by Website/Mobile with IF/THEN AC format. |
| 2.0 | 2026-06-02 | Grounded rewrite | All | REWRITE | Full rewrite grounded in contact-service, qontak-customer-fe, qontak.com, qontak-launchpad. |