Task Breakdown — RFC: Mention User in CDP Notes
Mode: Horizontal (Phase 1 UI → Phase 2 API/BE) · Scope: BE (contact-service) + Web FE (qontak-customer-fe) · Mobile (mobile-qontak-crm, chunks 8–9) excluded · Blocked tasks shown inline (full picture).
Update 2026-06-30: this breakdown now covers
NOTE-MENTION-S05(CHG-004, the mention preview card, synced from PRD v1.6 / RFC Decision 14 + chunks 11–12). It is web-only (mobile is deferred — no tappable chip in v1, Decision 7) and FE-only (nocontact-servicechange — the lookup is FE-direct to Launchpad). It splits across both phases: Task 1.4 (card UI, mocked detail) in Phase 1, Task 2.8 (UserStore.getUserDetail+ real Launchpad wiring) in Phase 2. Unlike the composer (gated on OQ-14 Figma), the preview card has design (15091-119388 / 15091-448947) and is buildable now.
Note on layout vs deploy order: the RFC mandates a BE-first dark deploy (§4.A) — Phase 2 BE ships behind
cdp_notes_mention_enabled(OFF) before the Phase 1 FE composer emits anchors. So Phase 1 appears first for planning, but Phase 2 BE chunks 1–5 are the ones a developer can start today; Phase 1 FE render work is also actionable now, while the typeahead picker UI is blocked on Figma (OQ-14).
Effort Summary
| Phase / Area | FE days | BE days | QA days | Total |
|---|---|---|---|---|
| Phase 1 — UI (mocked) | 7 | — | 2.0 | 9.0 |
| Phase 2 — API integration + BE | 2.5 | 10 | 3.5 | 16.0 |
| Grand total | 9.5 | 10 | 5.5 | 25.0 |
Confidence: medium. Key assumptions that could move this: (1) OQ-14 (Figma) for the typeahead picker + chip is still pending — it blocks the picker UI and chip styling, so the FE composer estimate assumes a standard pixel3/Tiptap picker once design lands; (2) OQ-7 editor spike (pixel3-native
MpRichTextEditorextension vs a custom@tiptap/extension-mentionfallback) could add a new npm dep and shift FE composer effort up by ~1 day; (3) BE estimates assume MongoDB schemaless field add (no data migration) and reuse of the existinggocraft/workworker + Launchpad client; (4) S05 preview card (Tasks 1.4 + 2.8, +4.5 FE/QA days) has Figma so is not gated on OQ-14, but two things could move it: the event-delegation hover plumbing overv-htmlcontent (the chip is not a Vue child, so the card can't be wrapped declaratively) and OQ-22 (the verifiedget_by_sso_iddetail response carries noteams[]/avatar, so teams are best-effort viagetTeams()or omitted — degraded, not broken).
Phase 1 — UI (APIs mocked)
Task 1.1: [FE] Note composer — @ typeahead, anchor insertion & save-path sanitize (NOTE-MENTION-S01)
A CS/Sales agent typing
@in a CDP contact note sees a company-user picker, selects a teammate, and the note saves with a mention anchor carrying that user's SSO UUID — on both new notes and edits.
Status: ⚠️ Partially blocked — the typeahead picker UI needs Figma frames (OQ-14) and the editor-extension decision (OQ-7 spike, fallback = Tiptap pre-committed). Actionable now: the DOMPurify ADD_ATTR save-path allow-list, the mention-anchor insertion contract (data-user-id + data-mention), and keeping the @ affordance enabled on edit (D-11).
Design reference: Web — mention typeahead picker — n/a — design pending (OQ-14, no Figma frame yet) · DS @mekari/pixel3@1.0.10-dev.0 (package.json:24) · frame name TBD · design QA TBD. Source: RFC §1 Design References. Do not implement the picker against an imagined design — the logic + mocked list are buildable now; the visual waits on the frame.
What to build
Extend the note composer so it (a) keeps data-user-id/data-mention attributes through the DOMPurify save sanitize, (b) inserts a mention anchor when a user is picked, and (c) renders an @-triggered picker (mocked user list for Phase 1). Picker visual + chip insert styling wait on Figma.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | features/customers/detail/components/Notes/components/NoteInput/NoteInput.vue | DOMPurify config gains ADD_ATTR: ['data-user-id','data-mention'] on the save path; @ typeahead trigger; insert mention anchor on select; @ affordance stays enabled in edit mode |
| create | features/customers/detail/components/Notes/components/NoteInput/MentionPicker.vue | New @-typeahead dropdown (mocked user source for now) — list company users, keyboard nav, select → emit user |
| extend | features/customers/mocks/notesMock.ts | Add mock company-user list for the picker |
| extend | features/customers/detail/components/Notes/components/NoteInput/NoteInput.spec.ts | Tests: data-* survive sanitize; select inserts anchor with sso_id; edit re-opens with @ enabled |
| create | features/customers/detail/components/Notes/components/NoteInput/MentionPicker.spec.ts | Tests: picker filters mocked users, emits selected user |
Implementation steps
- Write failing tests (red) — In
NoteInput.spec.tsadd tests assertingdata-user-id/data-mentionsurvive the save-path DOMPurify call, that selecting a user inserts an<a data-user-id="…" data-mention>anchor, and that the@affordance is enabled when the composer opens in edit mode. CreateMentionPicker.spec.tswith filter + emit tests. Runpnpm test -- NoteInput.spec.ts MentionPicker.spec.tsand confirm red. - Scaffold — Create
MentionPicker.vue(props:users,query; emitsselect); add the@trigger handler shell inNoteInput.vue. - Wire state (mocked) — Source the picker list from
notesMock.tsfor now; real company-user search is wired in Task 2.7 (stub the fetch). - Implement behavior — Add
ADD_ATTR: ['data-user-id','data-mention']to the existing DOMPurify call inNoteInput.vuesave path; implement anchor insertion onselect; ensure edit mode does not disable the@button (D-11). - Go green — Run
pnpm test -- NoteInput.spec.ts MentionPicker.spec.tsuntil green. - Quality gate —
pnpm lint && pnpm build.
The picker's visual (spacing, chip preview, avatar/initials) and the editor-extension path are gated on Figma (OQ-14) + the OQ-7 spike — build the logic + mocked list now; finalize visuals when design lands.
Acceptance criteria
- DOMPurify on the save path keeps
data-user-idanddata-mention(S01/AC-2). - Selecting a user inserts a mention anchor whose
data-user-idis the user's SSO UUID (S01/AC-2). - The
@affordance is enabled when editing an existing note (S01/AC-4, D-11). - (pending OQ-14) Typeahead picker matches the Figma frame and lists company users with initials/
full_name(avatar not in list response). - (pending OQ-7) Editor uses pixel3-native extension or the Tiptap fallback per spike outcome.
Test strategy
vitest on NoteInput.spec.ts mocks the user list and a DOMPurify pass, asserting data-* attributes survive and a selected user yields an anchor with the correct sso_id; MentionPicker.spec.ts asserts client-side filtering and the select emit.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 2.5 |
| Backend | — |
| QA | 0.5 |
| Total | 3.0 |
Assumptions: picker built against mocked data (real search in Task 2.7); Figma + OQ-7 resolved before the visual is finalized; reuses the existing DOMPurify integration already in
NoteInput.vue.
Run to verify
pnpm test -- NoteInput.spec.ts MentionPicker.spec.ts && pnpm lint
Depends on
- [External: OQ-14 Figma frames (pending)] · [External: OQ-7 editor spike (fallback pre-committed)]
Task 1.2: [FE] Notes list — mention chip render & render-path sanitize (NOTE-MENTION-S01)
A user viewing a contact's notes sees each mention rendered as a styled, non-editable
@Namechip instead of raw HTML or a plain link.
Status: ⚠️ Partially blocked — chip visual styling (tokens) needs Figma (OQ-14). Actionable now: the DOMPurify ADD_ATTR render-path allow-list so data-user-id/data-mention survive rendering.
Design reference: Web — mention chip (rendered note) — n/a — design pending (OQ-14, no Figma frame yet) · DS @mekari/pixel3@1.0.10-dev.0 · chip = styled non-editable @Name, token set TBD · design QA TBD. Source: RFC §1 Design References. Ship a placeholder chip style now; apply tokens when the frame lands.
What to build
Update the notes-list renderer so the mention anchor's data-* attributes survive the render-path DOMPurify pass and render as a chip. Phase 1 ships a minimal/placeholder chip style; final tokens land with Figma.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | features/customers/detail/components/Notes/components/NotesList/NotesList.vue | DOMPurify render config gains ADD_ATTR: ['data-user-id','data-mention']; mention anchor styled as a chip |
| create/extend | features/customers/detail/components/Notes/components/NotesList/NotesList.spec.ts | Tests: chip renders from a stored anchor; no raw HTML leaks; data-* survive render sanitize |
Implementation steps
- Write failing tests (red) — Add
NotesList.spec.tstests: a note whose HTML contains<a data-user-id data-mention>renders a chip element and thedata-*attributes survive the render DOMPurify call. Runpnpm test -- NotesList.spec.ts, confirm red. - Scaffold — Locate the existing DOMPurify render call in
NotesList.vue. - Implement behavior — Add
ADD_ATTR: ['data-user-id','data-mention']; add a.mention-chipstyle hook on the anchor. - Go green —
pnpm test -- NotesList.spec.tsuntil green. - Quality gate —
pnpm lint && pnpm build.
Final chip tokens (color/radius/spacing) are pending Figma (OQ-14) — ship a placeholder style now.
Acceptance criteria
- Render-path DOMPurify keeps
data-user-id/data-mention(so chips don't degrade to plain links). - A stored mention anchor renders as a non-editable
@Namechip; no raw HTML is shown. - (pending OQ-14) Chip uses the Figma-specified tokens.
Test strategy
vitest renders NotesList.vue with a note containing a mention anchor and asserts the chip element exists and data-* attributes are present after sanitize.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 1.5 |
| Backend | — |
| QA | 0.5 |
| Total | 2.0 |
Assumptions: reuses the existing DOMPurify call already present in
NotesList.vue; placeholder chip style acceptable until Figma.
Run to verify
pnpm test -- NotesList.spec.ts && pnpm lint
Depends on
- [External: OQ-14 Figma chip tokens (pending)]
Task 1.3: [FE] Deep-link to the Notes tab (OQ-17) (NOTE-MENTION-S03)
Clicking a mention notification in the web Notification Center opens the contact and auto-selects the Notes tab.
Status: ✅ Actionable — no external dependency; both changes live entirely in qontak-customer-fe.
Design reference: N/A — routing/tab-selection change only, no new visual surface (the Notes tab + Notification Center already ship). Source: RFC §1 Design References (no design surface listed for OQ-17).
What to build
Parse ?tab=notes in CustomerActivityV2.vue to auto-select the Notes tab, and remove the isStaging gate that currently hides the Notes tab.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | features/customers/detail/components/CustomerActivityV2/CustomerActivityV2.vue | Read ?tab=notes from route query → select Notes tab; remove isStaging===true gate on the Notes tab |
| create/extend | features/customers/detail/components/CustomerActivityV2/CustomerActivityV2.spec.ts | Test: navigating with ?tab=notes selects the Notes tab; tab visible without staging flag |
Implementation steps
- Write failing tests (red) — Add a spec asserting that mounting with route query
tab=notesselects the Notes tab and that the tab is visible regardless ofisStaging. Runpnpm test -- CustomerActivityV2.spec.ts, confirm red. - Implement behavior — Read
route.query.tabon mount/watch; set the active tab; delete theisStagingcondition guarding the Notes tab. - Go green —
pnpm test -- CustomerActivityV2.spec.ts. - Quality gate —
pnpm lint && pnpm build.
Acceptance criteria
-
/customers/{id}?tab=notesauto-selects the Notes tab (S03/AC-2 web tap-through). - The Notes tab is visible in production (no longer gated behind
isStaging).
Test strategy
vitest mounts CustomerActivityV2.vue with a mocked route carrying ?tab=notes and asserts the Notes tab is the active tab and is rendered without the staging flag.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 1.0 |
| Backend | — |
| QA | 0.5 |
| Total | 1.5 |
Assumptions: tab component already supports programmatic selection;
isStaginggate is a simple conditional.
Run to verify
pnpm test -- CustomerActivityV2.spec.ts && pnpm lint
Depends on
- None (independent; land any time before web GA).
Task 1.4: [FE] Mention preview card — hover card on the chip (mocked user-detail) (NOTE-MENTION-S05)
A user viewing a contact's notes hovers a rendered
@Namemention chip and a read-only card appears showing that teammate's name, email, staff level, and team(s) — with a graceful inactive/not-found and error state — then dismisses on cursor-away, leaving the note readable.
Status: ✅ Actionable — design exists (Figma 15091-119388 / 15091-448947) so this is not gated on OQ-14 (unlike the composer). Build the card + all states against a mocked getUserDetail now; real Launchpad wiring lands in Task 2.8. Depends on Task 1.2 (render-path ADD_ATTR keeps data-user-id on the chip so the card knows which sso_id to resolve).
Design reference: Web — mention preview card — happy 15091-119388 · inactive / not-found 15091-448947 · DS @mekari/pixel3@1.0.10-dev.0 · reuses MpPopover (NotesList.vue:31-46) + MpAvatar initials (NotesList.vue:20) · design QA TBD. Source: RFC §1 Design References, §2.A.
What to build
A new MentionPreviewCard.vue (avatar-initials, name, email, staff level, teams row) and the hover plumbing in NotesList.vue that opens it for the hovered chip. Implementation nuance: the note body is rendered with v-html="sanitizeHtml(note.note)" (NotesList.vue:49), so the chip is raw HTML, not a Vue child — the card cannot be attached by declaratively wrapping the chip in <MpPopover>. Use event delegation: listen for mouseenter/focus on .note-content [data-mention] anchors, read the anchor's data-user-id, and position a floating MpPopover/card anchored to that element. Phase 1 sources the detail from a mock keyed by sso_id (real call in Task 2.8).
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | features/customers/detail/components/Notes/components/NotesList/MentionPreviewCard.vue | Card: MpAvatar initials + name + email + staff level + teams row; loading skeleton, inactive/not-found (Figma 15091-448947), and "Couldn't load details" error states; teams row omitted when unresolved (OQ-22) |
| extend | features/customers/detail/components/Notes/components/NotesList/NotesList.vue | Event-delegated hover/focus on .note-content [data-mention]; read data-user-id; open MentionPreviewCard anchored to the chip via MpPopover; mocked detail source for now; dismiss on cursor-away / Esc |
| extend | features/customers/mocks/notesMock.ts | Add a mock user-detail map (sso_id → { full_name, email, staff_level, status }) incl. an inactive/404 fixture |
| create | features/customers/detail/components/Notes/components/NotesList/MentionPreviewCard.spec.ts | Card renders name/email/staff level/team(s); inactive state; error state; teams row omitted when teams absent |
| extend | features/customers/detail/components/Notes/components/NotesList/NotesList.spec.ts | Hovering a chip opens the card with the chip's sso_id; dismiss closes it and the note stays readable; 404 fixture → inactive card; 5xx fixture → error card |
Implementation steps
- Explore — Open
NotesList.vueand read the existingMpPopover/MpPopoverTrigger/MpPopoverContentactions-menu pattern (:31-46) and theMpAvatar :nameusage (:20); note that note content is injected viav-html(:49), so chips are not component instances — the card must be wired by delegation, not declarative nesting. - Write failing tests (red) — In
MentionPreviewCard.spec.tsassert the four states render from props (success / loading / inactive / error) and that the teams row is omitted whenteamsis empty. InNotesList.spec.tsassert that dispatching a hover on a[data-mention]anchor opens the card bound to that anchor'sdata-user-id(mocked detail), that dismiss closes it, and that a 404/5xx fixture renders the inactive/error card. Runnpm run test -- MentionPreviewCard NotesList, confirm red. - Scaffold — Create
MentionPreviewCard.vue(props:detail,state,teams); add the delegated hover handler shell + aMpPopoveranchored to the hovered chip inNotesList.vue. - Wire state (mocked) — Source the detail from
notesMock.tskeyed bysso_id; realUserStore.getUserDetailis wired in Task 2.8 (stub the fetch). - Implement behavior — Render the success/loading/inactive/error states per the Figma frames; omit the teams row when teams are unresolved (OQ-22); dismiss on cursor-away and
Esc; keep the card keyboard-focusable from the chip. - Go green —
npm run test -- MentionPreviewCard NotesListuntil green. - Quality gate —
npm run lint && npm run build.
Acceptance criteria
- Hovering a rendered mention chip opens a card showing name, email, staff level, and team(s) (S05/AC-1).
- When the user is in multiple teams, all are listed; when teams are unresolved, the row is omitted gracefully — never blocks the card (S05/AC-2, OQ-22).
- Dismissing (cursor-away /
Esc) closes the card and the note remains readable (S05/AC-3). - A 404 (inactive/deleted/unknown) fixture renders the inactive / not-found state, no thrown error (S05/ERR-1).
- A 5xx/timeout fixture renders "Couldn't load details"; the note stays readable (S05/ERR-2).
Test strategy
vitest mounts NotesList.vue with a note whose HTML carries a data-mention anchor and a mocked detail map; asserts a delegated hover opens MentionPreviewCard bound to the anchor's sso_id, that dismiss closes it, and that the inactive/error fixtures drive the corresponding card states. MentionPreviewCard.spec.ts covers each state and the teams-omitted fallback in isolation.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 2.0 |
| Backend | — |
| QA | 0.5 |
| Total | 2.5 |
Assumptions: card visual reuses pixel3
MpPopover/MpAvatar(design already specced), so the cost is the event-delegation hover plumbing overv-htmlcontent, not the card chrome; detail is mocked here (real wiring in Task 2.8). Depends on Task 1.2 shipping the render-pathADD_ATTRsodata-user-idsurvives.
Run to verify
npm run test -- MentionPreviewCard NotesList && npm run lint
Depends on
- [Task 1.2] (render-path
ADD_ATTRso the chip carriesdata-user-id)
Phase 2 — API Integration + Backend
Task 2.1: [BE] Note document model — mentioned_user_ids + append-only notified_user_ids (+ optional index) (NOTE-MENTION-S01, S03)
The note document can persist which company users are currently mentioned and which have already been notified, so renders and notification dedup have a stable key.
Status: ✅ Actionable.
What to build
Add two []string SSO-UUID fields to the ContactNote struct (Mongo, schemaless — old docs read as empty) and an optional index migration.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | internal/app/repository/contact_notes/base.go | Add MentionedUserIDs []string \bson:"mentioned_user_ids,omitempty"`andNotifiedUserIDs []string `bson:"notified_user_ids,omitempty"`toContactNote` |
| create | db/migrations/0NN_contact_notes_mention_index.up.json / .down.json | Optional index on mentioned_user_ids (for a future "mentions me" view) |
| extend | internal/app/repository/contact_notes/create_test.go / read_test.go | bson round-trip; absent fields on old docs → empty |
Implementation steps
- Write failing tests (red) — Add round-trip tests asserting both fields marshal/unmarshal and that a doc without them decodes to empty slices.
make test, confirm red. - Implement — Add the struct fields; create the up/down migration JSON.
- Go green —
make test. - Quality gate —
make build && make migrate-up(confirm index applies + rolls back).
Acceptance criteria
- Both fields compile and round-trip via bson; absent on old docs → empty.
- Index migration applies and rolls back cleanly.
Test strategy
Go table tests in the repo package assert bson round-trip and that legacy docs (no fields) decode to empty slices.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 0.5 |
| QA | 0 |
| Total | 0.5 |
Assumptions: MongoDB schemaless add — no data migration; index is optional and not required for v1 fan-out.
Run to verify
make test && make build && make migrate-up
Depends on
- None.
Task 2.2: [BE] mention.Parser — dual-form anchor parsing, max-10, UUID validation (NOTE-MENTION-S01, S02, S04)
The server extracts mention targets from note HTML regardless of whether the web client (
data-user-id) or the mobile client (href=.../users/{id}/edit_user) authored them, normalizing both to a list of SSO UUIDs.
Status: ✅ Actionable (the mobile anchor format is specced in D-9; no mobile repo needed to parse it).
What to build
A new mention package with a parser that reads both anchor encodings, dedups to []sso_id, drops malformed/non-UUID anchors, and errors (or drops-extras) past the max of 10 (CDP_NOTES_MENTION_MAX).
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | internal/app/service/mention/parser.go | Parse data-user-id (web) + href=".../users/{id}/edit_user" (mobile) → dedup []sso_id; UUID-validate; enforce max-10 |
| create | internal/app/service/mention/parser_test.go | Both forms → same ids; malformed dropped; >10 → error/drop-extras |
Implementation steps
- Write failing tests (red) — Table tests: web-form HTML and mobile-href HTML both yield the same
[]sso_id; non-UUID anchors dropped; 11 mentions → error (or drop-extras per Decision 6).make test, confirm red. - Implement — Build the parser using an HTML tokenizer; extract both anchor forms; validate UUID; cap at
CDP_NOTES_MENTION_MAX(default 10). - Go green —
make test. - Quality gate —
make lint && make build.
Acceptance criteria
- Web
data-user-idform and mobile href form both parse to the identical[]sso_id(S01/AC-3, S02/AC-2). - Malformed / non-UUID anchors are dropped.
- More than 10 mentions → error or drop-extras (Decision 6).
Test strategy
Go table tests assert dual-form equivalence, malformed-drop, and the max-10 boundary; the parser is pure (no I/O) so tests are fast and exhaustive.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 2.0 |
| QA | 0.5 |
| Total | 2.5 |
Assumptions: mobile anchor format fixed by D-9; uses Go stdlib
htmltokenizer; no DB or network in the parser.
Run to verify
make test && make lint
Depends on
- None (consumed by Task 2.5).
Task 2.3: [BE] Server-side HTML sanitizer — bluemonday allow-list (NOTE-MENTION-S04/ERR-2)
No matter which client (or a crafted API call) wrote the note, the server strips everything except a strict allow-list, so a mention anchor can't smuggle in an XSS payload.
Status: ✅ Actionable — requires infosec sign-off on the allow-list before AGREED (approver gate), but implementable now.
What to build
Add github.com/microcosm-cc/bluemonday, define a notePolicy matching the editor's toolbar capabilities + the mention anchor attributes, and run it on the note write path (create + update).
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | internal/app/service/mention/policy.go | notePolicy: allow p,br,strong,em,b,i,u,s,h1..h3,ul,ol,li,a; on a allow only href+data-user-id+data-mention; AllowURLSchemes("https") + relative ^\.\./.*\/edit_user$; reject javascript:/data:; UUID-pattern on data-user-id |
| extend | go.mod / go.sum | Add bluemonday |
| create | internal/app/service/mention/policy_test.go | Golden test: allow-listed formatting + mention anchor survive; <script>, onerror=, javascript:/data: href, style=, extra attrs stripped |
Implementation steps
- Write failing tests (red) — Golden-file test asserting (a) formatting + mention anchor survive and (b)
<script>/onerror=/javascript:/data:/style=/extra attrs are stripped.make test, confirm red. - Implement —
go getbluemonday; buildnotePolicyper the Decision 3 spec. - Go green —
make test. - Quality gate —
make sec && make build(gosec must stay clean).
Acceptance criteria
- Allow-listed formatting tags and the mention anchor survive sanitization.
-
<script>,on*=,javascript:/data:hrefs,style=,class,targetand any non-listed tag/attr are stripped. -
data-user-idmust match a UUID pattern, else the attribute is dropped.
Test strategy
Golden-file test over representative note HTML asserts both the positive (survival) and negative (strip) sets; make sec confirms no new gosec findings.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 1.5 |
| QA | 0.5 |
| Total | 2.0 |
Assumptions: new dependency approved; allow-list mirrors the existing toolbar (
heading/bold/italic/underline/strike/lists/links); infosec reviews the policy.
Run to verify
make test && make sec && make build
Depends on
- None (consumed by Task 2.5). Gate: infosec approval of the allow-list before AGREED.
Task 2.4: [BE] GetActiveUsersBySsoIds — company-scoped active-user validation (NOTE-MENTION-S04)
Given a list of SSO UUIDs and a company, the service can confirm which are active members of that company — the basis for dropping invalid/cross-company/deleted mentions.
Status: ✅ Actionable.
What to build
Add a thin GetActiveUsersBySsoIds(ctx, companySsoId, ssoIDs) ([]string, error) on IUserService that wraps the existing GetUsersByUserSsoIds (already returns SsoID+Status) and keeps only Status=="active".
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | internal/app/service/launchpad/user_service.go | New method + interface entry; wraps GetUserListBySsoIds/GetUsersByUserSsoIds, filters Status=="active" |
| extend | internal/app/service/launchpad/user_service_test.go | Given sso_ids + company → only active members returned; non-members/inactive excluded |
Implementation steps
- Write failing tests (red) — Mock the Launchpad client to return mixed active/inactive/non-member users; assert only active company members come back.
make test, confirm red. - Implement — Add the method + interface entry; filter on
Status=="active". - Go green —
make test. - Quality gate —
make lint && make build.
Acceptance criteria
- Returns only active members of the given company; inactive and non-members excluded.
- A Launchpad generic 404 (deleted or non-existent) → treated as not-active (drives drop-and-warn, ERR-3 / OQ-20).
Test strategy
Go tests with a mocked IUserService client assert the active-only filter and company scoping; covers the deleted-user 404 path.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 0.5 |
| QA | 0 |
| Total | 0.5 |
Assumptions: wraps the existing verified client method (
GetUsersByUserSsoIdsreturnsSsoID+Status); no new Launchpad endpoint needed.
Run to verify
make test && make lint
Depends on
- None (consumed by Task 2.5).
Task 2.5: [BE] Wire parse + validate + sanitize + persist into create/update; idempotent notified-set (NOTE-MENTION-S01, S04, S05-NEG)
When a note is created or edited, the server parses mentions, drops invalid/cross-company ones (saving the note anyway), persists the valid set, and computes exactly who still needs notifying — each user at most once per note lifetime.
Status: ✅ Actionable.
What to build
In ContactNotesService.CreateNote/UpdateNote: run the sanitizer (2.3) → parser (2.2) → validate via GetActiveUsersBySsoIds (2.4) with drop-and-warn → persist mentioned_user_ids → compute to_notify = parsed_valid − notified_user_ids − self, append to the append-only notified_user_ids, and return mentioned_user_ids + dropped_mentions in the response. Gate the whole path on cdp_notes_mention_enabled.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | internal/app/service/contact_notes/contact_notes_service.go | Orchestrate sanitize→parse→validate→persist; drop-and-warn; to_notify diff vs append-only set; self-filter; flag gate |
| extend | internal/app/service/contact_notes/payload/contact_notes_request.go (or response file) | Add mentioned_user_ids + dropped_mentions to ContactNoteResponse |
| extend | internal/app/service/contact_notes/contact_notes_service_test.go | create seeds set; edit computes diff; remove→re-add no re-notify (OQ-21); self filtered; cross-company/404 dropped + cdp_note_mention_invalid; flag OFF == today |
Implementation steps
- Write failing tests (red) — Tests for: create stores valid ids +
dropped_mentions+ seedsnotified_user_ids; edit computesto_notify = parsed − notified − selfand appends; remove-then-re-add does not re-notify (OQ-21); author self-filtered; invalid/cross-company/404 dropped withcdp_note_mention_invalid; flag OFF behaves byte-for-byte as today.make test, confirm red. - Implement — Call sanitizer → parser → validator; build the diff against the append-only set; return new response fields; wrap in the
cdp_notes_mention_enabledgate. - Go green —
make test. - Quality gate —
make lint && make sec && make build.
to_notify(the not-yet-notified set) is handed to the dispatch job in Task 2.6 — stub the enqueue call here until 2.6 lands.
Acceptance criteria
- Create persists valid
mentioned_user_ids, returnsdropped_mentions, seedsnotified_user_ids(S01/AC-3). - Edit computes
to_notify = parsed_valid − notified_user_ids − selfand appends to the append-only set (S01/AC-4, D-11). - A removed-then-re-added mention is not re-notified (OQ-21).
- Author is never in
to_notify(S03/ERR-2). - Invalid / cross-company / 404-deleted ids are dropped and
cdp_note_mention_invalidlogged (S04/ERR-1, ERR-3, S05-NEG/NEG-1). - Flag OFF → no parse/validate/dispatch; behavior identical to today (S05-NEG/NEG-2).
Test strategy
Go table tests with mocked parser/validator/enqueuer assert the persisted set, the append-only diff (incl. remove→re-add), self-filter, drop-and-warn logging, and the flag-OFF no-op path.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 2.5 |
| QA | 0.5 |
| Total | 3.0 |
Assumptions:
UpdateNotealready loads the prior note (GetNoteByID) so the priornotified_user_idsis in hand; reuses existing handler/response plumbing (shared by both route groups — OQ-15).
Run to verify
make test && make lint && make sec
Depends on
- [Task 2.2], [Task 2.3], [Task 2.4], [Task 2.1]
Task 2.6: [BE] NotificationClient + MentionNotifyJob — async dispatch via gocraft/work (NOTE-MENTION-S03)
Each newly-mentioned teammate receives a notification (web Notification Center + mobile FCM) asynchronously, with durable retries, without blocking the note write — and never twice for the same note.
Status: ✅ Actionable — the /crm contract is verified (Decision 10), no external blocker.
What to build
An outbound NotificationClient calling POST /api/v1/notifications/crm (notif_type=1, notif_category=2, recipient sso_id, click_action_url, X-Api-Key), and a MentionNotifyJob enqueued per id in to_notify (create + edit) via the existing IJobEnqueuer, with retry×3 backoff and a timestamp-free idempotency key (note_id + recipient_sso_id).
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | internal/app/api/notification_client.go | POST /api/v1/notifications/crm with finalized payload + X-Api-Key; timeout via NOTIFICATION_SERVICE_TIMEOUT |
| create | internal/app/service/.../mention_notify_job.go | MentionNotifyJob handler: send via client; 5xx → retry×3 → log cdp_note_mention_notify_failed; idempotency key dedupes retries + concurrent PUTs |
| extend | worker registration + job_enqueuer call site (Task 2.5) | register job; enqueue per to_notify id |
| create | *_test.go | request shape; enqueue-per-not-yet-notified (create+edit); no enqueue for already-notified/self; 5xx retry→log; note unaffected |
Implementation steps
- Write failing tests (red) — Tests: client sends
sso_id+title+description+notif_type=1+notif_category=2+click_action_urlwithX-Api-Key; job enqueued once perto_notifyid on create and edit; never for already-notified/self; 5xx → retry×3 →cdp_note_mention_notify_failed, note stays saved; idempotency key dedupes.make test, confirm red. - Implement — Build the client (config-driven URL/key/timeout); implement the job handler with retry/backoff + idempotency key; register the job; replace the Task 2.5 enqueue stub with the real call.
- Go green —
make test. - Quality gate —
make lint && make sec && make build.
Acceptance criteria
- Client POSTs the finalized
/crmpayload withX-Api-Key(Decision 10). - One job enqueued per
to_notifyid on create and edit; none for already-notified, self, or removed-then-re-added (S03/AC-1, AC-3). - 5xx → retry×3 backoff → logs
cdp_note_mention_notify_failed; the note stays saved (S03/ERR-1). - Idempotency key (
note_id + recipient_sso_id) dedupes retries and concurrent PUTs. -
organization_idset fromcompany_sso_id(UUID) or omitted; mobile route usesorigin=external_url(S03/AC-2b).
Test strategy
Go tests with a mocked HTTP server assert the request shape + headers; mocked enqueuer asserts per-recipient enqueue on create/edit and the retry-then-log failure path; idempotency-key test covers concurrent PUTs.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | — |
| Backend | 3.0 |
| QA | 1.0 |
| Total | 4.0 |
Assumptions: reuses the existing
gocraft/workworker + heimdall HTTP pattern; worker has the Notification-Service client +X-Api-Keyconfigured; auto-FCM fan-out forcrmorigin (OQ-19 infosec ack pending, not a build blocker).
Run to verify
make test && make lint && make sec && make build
Depends on
- [Task 2.5]
Task 2.7: [FE] Wire composer + renderer to real APIs — company-user search & response fields (NOTE-MENTION-S01)
The typeahead now lists real company users from Launchpad, and the UI reflects the server's
mentioned_user_ids/dropped_mentions(e.g. a warn chip when a mention was dropped).
Status: ✅ Actionable for the data wiring (real user search + response reads); the picker visual still rides on Task 1.1's Figma gate.
What to build
Replace the mocked picker source with the real company-user search (via UserStore/Launchpad), and read mentioned_user_ids + dropped_mentions from the note POST/PUT response in CustomerStore to drive the chip + drop-warn UI.
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | features/customers/store/UserStore.ts | Expose company active-user list/search for the picker (client-side filter; escalate to Launchpad ?query= if P95 > 500ms) |
| extend | features/customers/store/CustomerStore.ts | Read mentioned_user_ids + dropped_mentions from the note create/update response |
| extend | features/customers/detail/components/Notes/components/NoteInput/MentionPicker.vue | Swap mocked source → UserStore; show drop-warn chip from dropped_mentions |
| extend | NoteInput.spec.ts / MentionPicker.spec.ts | Replace mocks with real-store assertions; drop-warn chip shows when dropped_mentions non-empty |
Implementation steps
- Write failing tests (red) — Update specs to assert the picker sources from
UserStoreand that a non-emptydropped_mentionsrenders a warn chip.pnpm test, confirm red. - Wire state — Point
MentionPickeratUserStore; read response fields inCustomerStore. - Implement behavior — Client-side filter on the user list; drop-warn chip; null-safe
full_name(avatar absent → initials). - Go green —
pnpm test. - Quality gate —
pnpm lint && pnpm build.
Acceptance criteria
- Typeahead lists real active company users (S01/AC-1); P95 ≤ 500ms (escalate to server
?query=search if exceeded). - Empty/404 query → "No matching people" (S01/ERR-2).
- UI reflects
mentioned_user_ids; a dropped mention shows a warn chip fromdropped_mentions(S04/ERR-1).
Test strategy
vitest with a mocked UserStore and a mocked note response asserts the picker lists store users, the empty state copy, and the drop-warn chip on non-empty dropped_mentions.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 1.0 |
| Backend | — |
| QA | 0.5 |
| Total | 1.5 |
Assumptions:
UserStore.getUsers()already exists; client-side filter sufficient for v1; depends on Task 2.5 shipping the response fields.
Run to verify
pnpm test -- NoteInput.spec.ts MentionPicker.spec.ts && pnpm lint
Depends on
- [Task 1.1], [Task 2.5]
Task 2.8: [FE] UserStore.getUserDetail + session cache — wire the preview card to real Launchpad detail (NOTE-MENTION-S05)
The preview card now resolves real teammate identity from Launchpad — name, email, staff level, status — lazily on first hover and cached for the session, with teams resolved (or gracefully omitted) and a real inactive/not-found state.
Status: ✅ Actionable — the single-user detail source is verified BE-side (Launchpad GET /private/users/get_by_sso_id → LaunchpadUserDetailResponse{full_name, email, sso_id, staff_level, status}, qontak_launchpad.go:223-224,545-556). No contact-service change — this is FE-direct, mirroring the existing getUsers/getTeams Pinia pattern. The one thing to confirm is the FE-facing gateway path for get_by_sso_id on IAG_LAUNCHPAD_URL (the FE today calls /v1/users / /v1/teams); flag if it differs.
Design reference: same frames as Task 1.4 — happy 15091-119388 · inactive / not-found 15091-448947. No new design surface — this task swaps the mocked source for the live one.
What to build
A new UserStore.getUserDetail(ssoId) that calls Launchpad get_by_sso_id via IAG_LAUNCHPAD_URL, caches the result by sso_id in an in-store Map for the session, maps a generic 404 to an inactive/not-found sentinel, and surfaces 5xx as an error (not thrown to the view). Then swap MentionPreviewCard's mocked source to this method and resolve the teams row via the existing getTeams() list where a user→team mapping is available (else omit — OQ-22).
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | features/customers/store/UserStore.ts | New UserDetail type (full_name, email, sso_id, staff_level, status); getUserDetail(ssoId) → $customFetch on config.IAG_LAUNCHPAD_URL (get_by_sso_id?sso_id= — confirm FE path); session cache Map<sso_id, UserDetail | NotFound>; 404 → not-found sentinel, 5xx → error (no toast spam on hover) |
| extend | features/customers/detail/components/Notes/components/NotesList/NotesList.vue | Replace the mocked detail source with UserStore.getUserDetail; resolve teams via UserStore.getTeams() mapping or omit (OQ-22) |
| extend | features/customers/detail/components/Notes/components/NotesList/MentionPreviewCard.spec.ts / NotesList.spec.ts | Replace mock-source assertions with store-backed ones: resolves real fields; second hover on same sso_id hits cache (no refetch); 404 → inactive; 5xx → error |
Implementation steps
- Explore — Open
UserStore.tsand readgetTeams(:62) andgetUsers(:83): both use$customFetchwithbaseURL: config.IAG_LAUNCHPAD_URLand a/v1/...path. Mirror that exactly forgetUserDetail; reuse the sametoastNotifyerror idiom but suppress the toast on hover-resolution (a failed hover should degrade the card, not pop a toast). - Write failing tests (red) — In
NotesList.spec.ts/MentionPreviewCard.spec.tsassert the card sources fromUserStore.getUserDetail, that a repeat hover on the samesso_iddoes not refetch (cache hit), and that 404/5xx drive the inactive/error states. Runnpm run test -- MentionPreviewCard NotesList, confirm red. - Implement store method — Add
UserDetailtype +getUserDetail(ssoId)with the sessionMapcache; map 404 → not-found sentinel; return an error marker on 5xx. - Wire the card — Point
MentionPreviewCard/NotesList.vueatgetUserDetail; resolve teams viagetTeams()mapping where available, else omit (OQ-22). - Go green —
npm run test -- MentionPreviewCard NotesList. - Quality gate —
npm run lint && npm run build.
Acceptance criteria
- Hovering a chip resolves the real user via Launchpad
get_by_sso_idand renders name/email/staff level/team(s) (S05/AC-1). - A second hover on the same
sso_idis served from the session cache (no refetch). - Generic 404 (deleted/inactive/unknown — OQ-20) → inactive/not-found card, never throws (S05/ERR-1).
- 5xx/timeout → "Couldn't load details"; the note stays readable (S05/ERR-2).
- Teams resolved via
getTeams()where mappable; row omitted (not errored) when unresolved (S05/AC-2, OQ-22).
Test strategy
vitest with a mocked $customFetch asserts getUserDetail returns the mapped detail, caches by sso_id, and yields the not-found/error sentinels on 404/5xx; the card specs assert each sentinel drives the right state and that repeat hovers don't refetch.
Effort estimate
| Discipline | Days |
|---|---|
| Frontend | 1.5 |
| Backend | — |
| QA | 0.5 |
| Total | 2.0 |
Assumptions:
getUserDetailmirrors the verifiedgetUsers/getTeams$customFetchpattern; client-side session cache sufficient for v1; teams resolution is best-effort (OQ-22) — the companygetTeams()list has no per-user mapping, so v1 likely ships name/email/staff level and omits teams unless a mapping is wired. FE-facingget_by_sso_idgateway path to confirm.
Run to verify
npm run test -- MentionPreviewCard NotesList && npm run lint
Depends on
- [Task 1.4]
Ordering rationale
- Deploy BE-first, dark (§4.A). Although Phase 1 (UI) is listed first for planning, the RFC mandates BE chunks ship behind
cdp_notes_mention_enabled(OFF) before the FE emits anchors — otherwise an old BE stores unsanitized anchors it never parses. So the critical path is Phase 2 BE: 2.1 → (2.2 ‖ 2.3 ‖ 2.4) → 2.5 → 2.6. - 2.1–2.4 are independent and parallelizable (field add, parser, sanitizer, validator) — fan them out to land before the 2.5 orchestration that consumes all four.
- 2.5 then 2.6 is the spine: 2.5 computes
to_notify, 2.6 dispatches it. 2.5 can stub the enqueue so it merges before 2.6 is done. - FE render + DOMPurify (1.1 partial, 1.2, 1.3) is actionable today in parallel with BE — none of it needs the picker design. 1.3 (deep-link) is fully independent and can ship any time.
- S05 preview card (1.4 → 2.8) is actionable now and off the OQ-14 critical path — its design exists (15091-119388 / 15091-448947), so unlike the composer it doesn't wait on Figma. Sequence: 1.4 (card UI, mocked) → 2.8 (real
getUserDetailwiring), after 1.2 ships the render-pathADD_ATTR(so the chip carriesdata-user-id). The store method in 2.8 can be built independently of the card. Teams on the card are best-effort (OQ-22) — ship name/email/staff level, add teams viagetTeams()if mappable, else omit; don't let it block. The card is read-only and FE-direct, so it touches no BE work on the 2.1→2.6 spine. - Push externally on OQ-14 (Figma) — it is the only hard blocker, and it gates just the typeahead picker visual (Task 1.1) + chip tokens (Task 1.2). The OQ-7 editor spike can run in parallel; its fallback (Tiptap) is pre-committed so it never blocks. Get infosec to sign off the bluemonday allow-list (Task 2.3) before AGREED, and the OQ-19 lock-screen-PII ack folded into the same gate.
Skipped stories
| Story / Task | Reason |
|---|---|
NOTE-MENTION-S02 (Mobile compose) | Out of requested scope — mobile repo (mobile-qontak-crm, Flutter) not provided. RFC chunk 8: mention_toolbar_botton.dart emits sso_id in the href. The BE half (parser accepting the mobile href form) is covered in Task 2.2. |
NOTE-MENTION-S03 (Mobile tap-through, chunk 9) | Out of requested scope — verify-only mobile work confirming the external_url route opens click_action_url in mobile-qontak-crm. Web tap-through is covered in Task 1.3; BE dispatch (origin=external_url) in Task 2.6. |
NOTE-MENTION-S05 (Mobile preview card) | Deferred to v1.x — mobile renders mentions as plain text with no chip in v1 (Decision 7), so there is no tappable target. The mobile card ships with the deferred mobile chip (Decision 14). The web preview card is fully covered in Tasks 1.4 + 2.8. |
| Typeahead picker visual + chip tokens (within Tasks 1.1 / 1.2) | Blocked on OQ-14 — web Figma frames not yet created. Anchor/sanitize logic and a placeholder/mocked picker are buildable now; only the final visuals wait. |