Skip to main content

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 (no contact-service change — 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 / AreaFE daysBE daysQA daysTotal
Phase 1 — UI (mocked)72.09.0
Phase 2 — API integration + BE2.5103.516.0
Grand total9.5105.525.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 MpRichTextEditor extension vs a custom @tiptap/extension-mention fallback) 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 existing gocraft/work worker + 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 over v-html content (the chip is not a Vue child, so the card can't be wrapped declaratively) and OQ-22 (the verified get_by_sso_id detail response carries no teams[]/avatar, so teams are best-effort via getTeams() 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

ActionFileWhat changes
extendfeatures/customers/detail/components/Notes/components/NoteInput/NoteInput.vueDOMPurify 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
createfeatures/customers/detail/components/Notes/components/NoteInput/MentionPicker.vueNew @-typeahead dropdown (mocked user source for now) — list company users, keyboard nav, select → emit user
extendfeatures/customers/mocks/notesMock.tsAdd mock company-user list for the picker
extendfeatures/customers/detail/components/Notes/components/NoteInput/NoteInput.spec.tsTests: data-* survive sanitize; select inserts anchor with sso_id; edit re-opens with @ enabled
createfeatures/customers/detail/components/Notes/components/NoteInput/MentionPicker.spec.tsTests: picker filters mocked users, emits selected user

Implementation steps

  1. Write failing tests (red) — In NoteInput.spec.ts add tests asserting data-user-id/data-mention survive 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. Create MentionPicker.spec.ts with filter + emit tests. Run pnpm test -- NoteInput.spec.ts MentionPicker.spec.ts and confirm red.
  2. Scaffold — Create MentionPicker.vue (props: users, query; emits select); add the @ trigger handler shell in NoteInput.vue.
  3. Wire state (mocked) — Source the picker list from notesMock.ts for now; real company-user search is wired in Task 2.7 (stub the fetch).
  4. Implement behavior — Add ADD_ATTR: ['data-user-id','data-mention'] to the existing DOMPurify call in NoteInput.vue save path; implement anchor insertion on select; ensure edit mode does not disable the @ button (D-11).
  5. Go green — Run pnpm test -- NoteInput.spec.ts MentionPicker.spec.ts until green.
  6. Quality gatepnpm 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-id and data-mention (S01/AC-2).
  • Selecting a user inserts a mention anchor whose data-user-id is 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

DisciplineDays
Frontend2.5
Backend
QA0.5
Total3.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 @Name chip 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

ActionFileWhat changes
extendfeatures/customers/detail/components/Notes/components/NotesList/NotesList.vueDOMPurify render config gains ADD_ATTR: ['data-user-id','data-mention']; mention anchor styled as a chip
create/extendfeatures/customers/detail/components/Notes/components/NotesList/NotesList.spec.tsTests: chip renders from a stored anchor; no raw HTML leaks; data-* survive render sanitize

Implementation steps

  1. Write failing tests (red) — Add NotesList.spec.ts tests: a note whose HTML contains <a data-user-id data-mention> renders a chip element and the data-* attributes survive the render DOMPurify call. Run pnpm test -- NotesList.spec.ts, confirm red.
  2. Scaffold — Locate the existing DOMPurify render call in NotesList.vue.
  3. Implement behavior — Add ADD_ATTR: ['data-user-id','data-mention']; add a .mention-chip style hook on the anchor.
  4. Go greenpnpm test -- NotesList.spec.ts until green.
  5. Quality gatepnpm 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 @Name chip; 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

DisciplineDays
Frontend1.5
Backend
QA0.5
Total2.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)]

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

ActionFileWhat changes
extendfeatures/customers/detail/components/CustomerActivityV2/CustomerActivityV2.vueRead ?tab=notes from route query → select Notes tab; remove isStaging===true gate on the Notes tab
create/extendfeatures/customers/detail/components/CustomerActivityV2/CustomerActivityV2.spec.tsTest: navigating with ?tab=notes selects the Notes tab; tab visible without staging flag

Implementation steps

  1. Write failing tests (red) — Add a spec asserting that mounting with route query tab=notes selects the Notes tab and that the tab is visible regardless of isStaging. Run pnpm test -- CustomerActivityV2.spec.ts, confirm red.
  2. Implement behavior — Read route.query.tab on mount/watch; set the active tab; delete the isStaging condition guarding the Notes tab.
  3. Go greenpnpm test -- CustomerActivityV2.spec.ts.
  4. Quality gatepnpm lint && pnpm build.

Acceptance criteria

  • /customers/{id}?tab=notes auto-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

DisciplineDays
Frontend1.0
Backend
QA0.5
Total1.5

Assumptions: tab component already supports programmatic selection; isStaging gate 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 @Name mention 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

ActionFileWhat changes
createfeatures/customers/detail/components/Notes/components/NotesList/MentionPreviewCard.vueCard: 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)
extendfeatures/customers/detail/components/Notes/components/NotesList/NotesList.vueEvent-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
extendfeatures/customers/mocks/notesMock.tsAdd a mock user-detail map (sso_id{ full_name, email, staff_level, status }) incl. an inactive/404 fixture
createfeatures/customers/detail/components/Notes/components/NotesList/MentionPreviewCard.spec.tsCard renders name/email/staff level/team(s); inactive state; error state; teams row omitted when teams absent
extendfeatures/customers/detail/components/Notes/components/NotesList/NotesList.spec.tsHovering 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

  1. Explore — Open NotesList.vue and read the existing MpPopover/MpPopoverTrigger/MpPopoverContent actions-menu pattern (:31-46) and the MpAvatar :name usage (:20); note that note content is injected via v-html (:49), so chips are not component instances — the card must be wired by delegation, not declarative nesting.
  2. Write failing tests (red) — In MentionPreviewCard.spec.ts assert the four states render from props (success / loading / inactive / error) and that the teams row is omitted when teams is empty. In NotesList.spec.ts assert that dispatching a hover on a [data-mention] anchor opens the card bound to that anchor's data-user-id (mocked detail), that dismiss closes it, and that a 404/5xx fixture renders the inactive/error card. Run npm run test -- MentionPreviewCard NotesList, confirm red.
  3. Scaffold — Create MentionPreviewCard.vue (props: detail, state, teams); add the delegated hover handler shell + a MpPopover anchored to the hovered chip in NotesList.vue.
  4. Wire state (mocked) — Source the detail from notesMock.ts keyed by sso_id; real UserStore.getUserDetail is wired in Task 2.8 (stub the fetch).
  5. 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.
  6. Go greennpm run test -- MentionPreviewCard NotesList until green.
  7. Quality gatenpm 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

DisciplineDays
Frontend2.0
Backend
QA0.5
Total2.5

Assumptions: card visual reuses pixel3 MpPopover/MpAvatar (design already specced), so the cost is the event-delegation hover plumbing over v-html content, not the card chrome; detail is mocked here (real wiring in Task 2.8). Depends on Task 1.2 shipping the render-path ADD_ATTR so data-user-id survives.

Run to verify

npm run test -- MentionPreviewCard NotesList && npm run lint

Depends on

  • [Task 1.2] (render-path ADD_ATTR so the chip carries data-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

ActionFileWhat changes
extendinternal/app/repository/contact_notes/base.goAdd MentionedUserIDs []string \bson:"mentioned_user_ids,omitempty"`andNotifiedUserIDs []string `bson:"notified_user_ids,omitempty"`toContactNote`
createdb/migrations/0NN_contact_notes_mention_index.up.json / .down.jsonOptional index on mentioned_user_ids (for a future "mentions me" view)
extendinternal/app/repository/contact_notes/create_test.go / read_test.gobson round-trip; absent fields on old docs → empty

Implementation steps

  1. 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.
  2. Implement — Add the struct fields; create the up/down migration JSON.
  3. Go greenmake test.
  4. Quality gatemake 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

DisciplineDays
Frontend
Backend0.5
QA0
Total0.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

ActionFileWhat changes
createinternal/app/service/mention/parser.goParse data-user-id (web) + href=".../users/{id}/edit_user" (mobile) → dedup []sso_id; UUID-validate; enforce max-10
createinternal/app/service/mention/parser_test.goBoth forms → same ids; malformed dropped; >10 → error/drop-extras

Implementation steps

  1. 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.
  2. Implement — Build the parser using an HTML tokenizer; extract both anchor forms; validate UUID; cap at CDP_NOTES_MENTION_MAX (default 10).
  3. Go greenmake test.
  4. Quality gatemake lint && make build.

Acceptance criteria

  • Web data-user-id form 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

DisciplineDays
Frontend
Backend2.0
QA0.5
Total2.5

Assumptions: mobile anchor format fixed by D-9; uses Go stdlib html tokenizer; 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

ActionFileWhat changes
createinternal/app/service/mention/policy.gonotePolicy: 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
extendgo.mod / go.sumAdd bluemonday
createinternal/app/service/mention/policy_test.goGolden test: allow-listed formatting + mention anchor survive; <script>, onerror=, javascript:/data: href, style=, extra attrs stripped

Implementation steps

  1. 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.
  2. Implementgo get bluemonday; build notePolicy per the Decision 3 spec.
  3. Go greenmake test.
  4. Quality gatemake 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, target and any non-listed tag/attr are stripped.
  • data-user-id must 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

DisciplineDays
Frontend
Backend1.5
QA0.5
Total2.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

ActionFileWhat changes
extendinternal/app/service/launchpad/user_service.goNew method + interface entry; wraps GetUserListBySsoIds/GetUsersByUserSsoIds, filters Status=="active"
extendinternal/app/service/launchpad/user_service_test.goGiven sso_ids + company → only active members returned; non-members/inactive excluded

Implementation steps

  1. 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.
  2. Implement — Add the method + interface entry; filter on Status=="active".
  3. Go greenmake test.
  4. Quality gatemake 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

DisciplineDays
Frontend
Backend0.5
QA0
Total0.5

Assumptions: wraps the existing verified client method (GetUsersByUserSsoIds returns SsoID+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

ActionFileWhat changes
extendinternal/app/service/contact_notes/contact_notes_service.goOrchestrate sanitize→parse→validate→persist; drop-and-warn; to_notify diff vs append-only set; self-filter; flag gate
extendinternal/app/service/contact_notes/payload/contact_notes_request.go (or response file)Add mentioned_user_ids + dropped_mentions to ContactNoteResponse
extendinternal/app/service/contact_notes/contact_notes_service_test.gocreate 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

  1. Write failing tests (red) — Tests for: create stores valid ids + dropped_mentions + seeds notified_user_ids; edit computes to_notify = parsed − notified − self and appends; remove-then-re-add does not re-notify (OQ-21); author self-filtered; invalid/cross-company/404 dropped with cdp_note_mention_invalid; flag OFF behaves byte-for-byte as today. make test, confirm red.
  2. Implement — Call sanitizer → parser → validator; build the diff against the append-only set; return new response fields; wrap in the cdp_notes_mention_enabled gate.
  3. Go greenmake test.
  4. Quality gatemake 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, returns dropped_mentions, seeds notified_user_ids (S01/AC-3).
  • Edit computes to_notify = parsed_valid − notified_user_ids − self and 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_invalid logged (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

DisciplineDays
Frontend
Backend2.5
QA0.5
Total3.0

Assumptions: UpdateNote already loads the prior note (GetNoteByID) so the prior notified_user_ids is 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

ActionFileWhat changes
createinternal/app/api/notification_client.goPOST /api/v1/notifications/crm with finalized payload + X-Api-Key; timeout via NOTIFICATION_SERVICE_TIMEOUT
createinternal/app/service/.../mention_notify_job.goMentionNotifyJob handler: send via client; 5xx → retry×3 → log cdp_note_mention_notify_failed; idempotency key dedupes retries + concurrent PUTs
extendworker registration + job_enqueuer call site (Task 2.5)register job; enqueue per to_notify id
create*_test.gorequest shape; enqueue-per-not-yet-notified (create+edit); no enqueue for already-notified/self; 5xx retry→log; note unaffected

Implementation steps

  1. Write failing tests (red) — Tests: client sends sso_id+title+description+notif_type=1+notif_category=2+click_action_url with X-Api-Key; job enqueued once per to_notify id 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.
  2. 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.
  3. Go greenmake test.
  4. Quality gatemake lint && make sec && make build.

Acceptance criteria

  • Client POSTs the finalized /crm payload with X-Api-Key (Decision 10).
  • One job enqueued per to_notify id 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_id set from company_sso_id (UUID) or omitted; mobile route uses origin=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

DisciplineDays
Frontend
Backend3.0
QA1.0
Total4.0

Assumptions: reuses the existing gocraft/work worker + heimdall HTTP pattern; worker has the Notification-Service client + X-Api-Key configured; auto-FCM fan-out for crm origin (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

ActionFileWhat changes
extendfeatures/customers/store/UserStore.tsExpose company active-user list/search for the picker (client-side filter; escalate to Launchpad ?query= if P95 > 500ms)
extendfeatures/customers/store/CustomerStore.tsRead mentioned_user_ids + dropped_mentions from the note create/update response
extendfeatures/customers/detail/components/Notes/components/NoteInput/MentionPicker.vueSwap mocked source → UserStore; show drop-warn chip from dropped_mentions
extendNoteInput.spec.ts / MentionPicker.spec.tsReplace mocks with real-store assertions; drop-warn chip shows when dropped_mentions non-empty

Implementation steps

  1. Write failing tests (red) — Update specs to assert the picker sources from UserStore and that a non-empty dropped_mentions renders a warn chip. pnpm test, confirm red.
  2. Wire state — Point MentionPicker at UserStore; read response fields in CustomerStore.
  3. Implement behavior — Client-side filter on the user list; drop-warn chip; null-safe full_name (avatar absent → initials).
  4. Go greenpnpm test.
  5. Quality gatepnpm 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 from dropped_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

DisciplineDays
Frontend1.0
Backend
QA0.5
Total1.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_idLaunchpadUserDetailResponse{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

ActionFileWhat changes
extendfeatures/customers/store/UserStore.tsNew 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)
extendfeatures/customers/detail/components/Notes/components/NotesList/NotesList.vueReplace the mocked detail source with UserStore.getUserDetail; resolve teams via UserStore.getTeams() mapping or omit (OQ-22)
extendfeatures/customers/detail/components/Notes/components/NotesList/MentionPreviewCard.spec.ts / NotesList.spec.tsReplace 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

  1. Explore — Open UserStore.ts and read getTeams (:62) and getUsers (:83): both use $customFetch with baseURL: config.IAG_LAUNCHPAD_URL and a /v1/... path. Mirror that exactly for getUserDetail; reuse the same toastNotify error idiom but suppress the toast on hover-resolution (a failed hover should degrade the card, not pop a toast).
  2. Write failing tests (red) — In NotesList.spec.ts/MentionPreviewCard.spec.ts assert the card sources from UserStore.getUserDetail, that a repeat hover on the same sso_id does not refetch (cache hit), and that 404/5xx drive the inactive/error states. Run npm run test -- MentionPreviewCard NotesList, confirm red.
  3. Implement store method — Add UserDetail type + getUserDetail(ssoId) with the session Map cache; map 404 → not-found sentinel; return an error marker on 5xx.
  4. Wire the card — Point MentionPreviewCard/NotesList.vue at getUserDetail; resolve teams via getTeams() mapping where available, else omit (OQ-22).
  5. Go greennpm run test -- MentionPreviewCard NotesList.
  6. Quality gatenpm run lint && npm run build.

Acceptance criteria

  • Hovering a chip resolves the real user via Launchpad get_by_sso_id and renders name/email/staff level/team(s) (S05/AC-1).
  • A second hover on the same sso_id is 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

DisciplineDays
Frontend1.5
Backend
QA0.5
Total2.0

Assumptions: getUserDetail mirrors the verified getUsers/getTeams $customFetch pattern; client-side session cache sufficient for v1; teams resolution is best-effort (OQ-22) — the company getTeams() list has no per-user mapping, so v1 likely ships name/email/staff level and omits teams unless a mapping is wired. FE-facing get_by_sso_id gateway 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 getUserDetail wiring), after 1.2 ships the render-path ADD_ATTR (so the chip carries data-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 via getTeams() 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 / TaskReason
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.