Skip to main content

RFC: Mention User in CDP Notes

Document Conventions (do not remove)

This RFC follows the Qontak RFC Template format for governance — the Metadata table, Confluence sections 1–6, and Comment logs are mandatory; sections that do not apply are marked N/A — reason rather than deleted.

It is also agent-execution-ready: §1 Design References (FE half) + §1 PRD-to-Schema Derivation (BE half), the §2 Repo Reading Guide with Source Verification, the mermaid diagrams, the §2.G Cross-Layer Contract Verification, and the §4 Agent Execution Plan + Verification & Rollback Recipe are the readiness gates checked in §7.

The YAML frontmatter at the top is the machine-readable index; the Metadata table below is the human-readable governance record. Both agree on every shared field. The YAML status: key uses the linter enum (draft); the human label IDEA lives only in the Metadata table.

Verification provenance. All repo:path:line anchors in this RFC were opened and confirmed in the worktrees on 2026-06-18 (see §2 Source Verification). Where the PRD's line numbers had drifted, the verified numbers are used here and the drift is noted.

Re-verification 2026-06-22 (this revision). The workspace now contains four repos that were previously absent: notification-service (the real Unified Notification Service), qontak-unified-component (the web Notification Center), hub/hub-chat (the Qontak Chat FE hosts that import the unified component and embed qontak-customer-fe as an MFE), qontak-launchpad, and the second mobile app mobile-qontak-chat. The previously UNVERIFIED notification contract (OQ-1), Launchpad user-search (OQ-6), and company-UUID mapping (OQ-8) are now verified against code and several prior assumptions were corrected (see Comment log 2026-06-22 and §2 Decisions 9–13). Most notably: the ingestion endpoint for a CDP mention is /crm, not /chat; there is no skip_fcm field; organization_id is optional metadata; and the web rendering + MFE embed path is fully grounded.

Sync 2026-06-26 to PRD v1.5 (this revision). The source PRD advanced v1.2 → v1.5 while this RFC tracked the v1.2 baseline. This revision absorbs the three product deltas: (1) idempotent notification (PRD D-4/D-11, v1.5) — editing a note keeps the mention affordance enabled (re-mention + add allowed), and a mentioned user is notified at most once per note lifetime, tracked by an append-only persisted notified set keyed (note_id, sso_id) (mirrors CRM's Crm::Notification.find_or_create_by); a removed-then-re-added mention is not re-notified (PRD OQ-14). This replaces the earlier "diff vs the currently-stored set" logic — see the rewritten Decision 4 and the new notified_user_ids doc field. (2) User-not-found semantics (PRD v1.4) — Launchpad returns a generic 404 for any unresolved user, with no per-user flag to tell a deleted user from a non-existent one; the picker shows "No matching people" and validation drops/rejects either way (new S01/ERR-2, S04/ERR-3; RFC OQ-20). (3) Observabilitycdp_note_mention_added now fires on create or edit and carries new_mention_count + event. The grounded /crm endpoint correction (vs the PRD's stale /chat) is retained — the RFC is the engineering doc and the in-workspace notification-service is the source of truth (Decision 10).

Sync 2026-06-30 to PRD v1.6 (this revision). Absorbs the one v1.6 product delta — CHG-004: a mention preview card on the notes list (new story NOTE-MENTION-S05): hovering (web) / tapping (mobile) a rendered mention chip opens a read-only card resolving the mentioned user's name, email, staff level, and team(s) from the note's stored mentioned_user_ids via Launchpad (Figma 15091-119388), with an inactive / not-found state (Figma 15091-448947) reusing the same generic-404 semantics as OQ-20. This is captured as a new read-only path — no new persisted field, no change to the write path or notification dispatch. Two grounding facts shape it (new Decision 14): (a) the verified single-user lookup IUserService.GetUserDetailLaunchpadUserDetailResponse carries full_name/email/staff_level/status but no teams[] and no avatar (qontak_launchpad.go:545-556), so team resolution is a real gap (OQ-22, with a degraded-but-not-broken fallback); and (b) mobile renders mentions as plain text with no chip in v1 (Decision 7), so there is no tappable target — the mobile preview card is deferred to the follow-up that introduces the mobile chip; web ships the card in v1. The guard-rail story is renumbered NOTE-MENTION-S05-NEG → S06-NEG to match PRD v1.6. Unlike the composer (still gated on OQ-14 Figma), the preview card has design (15091-119388 / 15091-448947) and is buildable now.

Metadata

FieldValueNotes
StatusIDEAHuman label IDEA/RFC/AGREED/ABANDON; YAML status: carries the linter enum (draft).
DRITBD (CDP Squad eng lead)RFC owner (frontmatter dri).
Typefull-stackFE (qontak-customer-fe, embedded as MFE in hub/hub-chat) + BE (contact-service) + Mobile (mobile-qontak-crm only) + reused platform (notification-service, qontak-unified-component Notification Center, qontak-launchpad).
Author(s)CDP SquadPrimary author(s).
ReviewersCDP Squad (BE+FE), Mobile Squad, Notification/Platform SquadTech reviewers across affected squads.
Approver(s)TBD tech lead + TBD infosec approverInfosec approval required before AGREED (stores user-authored HTML server-side — D-5).
Submitted Date2026-06-18ISO-8601.
Last Updated2026-06-30ISO-8601; bump on every material edit. (2026-06-30: synced to PRD v1.6 — added CHG-004 / NOTE-MENTION-S05 mention preview card [web ships; mobile deferred to the chip], Decision 14, OQ-22 team/avatar resolution gap; guard rail renumbered S05-NEG→S06-NEG. 2026-06-26: synced to PRD v1.5 — idempotent per-note notification + append-only notified_user_ids [D-4/D-11/OQ-14], not-found-user semantics [v1.4], observability new_mention_count/event. 2026-06-22: notification contract re-verified against in-workspace notification-service, qontak-unified-component, hub/hub-chat, qontak-launchpad, mobile-qontak-chat.)
Target Release2026-Q3Carried from source PRD.
Target Quarter2026-Q3Advisory; from PRD/initiative README.
Source PRD../prds/prd-notes-mention-user.mdMirrors frontmatter related.
Discussion#cdp-ops (Slack)Thread TBD.

Type: full-stack Frontend sub-type: new-feature · Backend sub-type: new-feature · Mobile sub-type: enhancement

Sections at a Glance

  1. Overview (Design References — FE half; PRD-to-Schema Derivation — BE half; traceability; decisions; per-story map)
  2. Technical Design (Infra Topology → Technical Decisions [ADR] → Repo Reading Guide + Source Verification → architecture/ER/state/branch mermaid → end-to-end sequences → document model → APIs → integrity/concurrency/async → responsibility boundary → cross-layer contract → e2e data flow)
  3. High-Availability & Security
  4. Backwards Compatibility and Rollout Plan (cross-layer rollout matrix, Agent Execution Plan, Verification & Rollback Recipe)
  5. Concern, Questions, or Known Limitations
  6. Comment logs
  7. Ready for agent execution

1. Overview

Add the ability to @mention a Qontak One user inside a CDP contact note and notify the mentioned user, at parity with the legacy CRM person-note mention workflow. A mention is stored as a resolved tag keyed to the user's SSO UUID (PRD D-1), validated against the note's company, and delivered via notification-service (POST /api/v1/notifications/crm), which fans out to the web Notification Center (qontak-unified-component, hosted in hub/hub-chat) and mobile One Notification V2 (mobile-qontak-crm).

This is a delta on the existing CDP Notes feature. Note create/read/update/ delete, attachments, ownership, and permissions are unchanged; notes without mentions behave exactly as today (verified: no mention logic exists in contact-service, qontak-customer-fe, or mobile-qontak-crm — §2 Source Verification rows V-B6, V-F4, V-M-cross).

The change spans the following repos (all now in the workspace and verified on 2026-06-22):

  • contact-service (Go, chi, MongoDB) — parse mention anchors on the note write path, validate each SSO UUID against the company's active users, sanitize the stored HTML server-side, persist mentioned_user_ids, and dispatch a notification per not-yet-notified mention (idempotent per note — D-4/D-11) via the existing async worker.
  • qontak-customer-fe (Vue 3 / Nuxt 4, @mekari/pixel3) — an @-typeahead in the note composer (feasibility spike — OQ-7), DOMPurify allow-listing of the mention attributes on both the save and render paths, a mention chip, and a read-only mention preview card (CHG-004 / S05) that resolves the chip's stored sso_id to the user's name/email/staff level/team(s) on hover, reusing the existing MpPopover pattern (verified NotesList.vue:31-46) + a new UserStore.getUserDetail(ssoId) (Decision 14). This app is embedded as a Micro-Frontend (module federation, RemoteContactRouter) inside hub/hub-chat at /customers/** (verified qontak-customer-fe/nuxt.config.ts:92-111, hub-chat/nuxt.config.ts:526, hub-chat/pages/customers/[...all].vue:69).
  • notification-service (Go) — the real Unified Notification Service. A CDP mention is ingested via POST /api/v1/notifications/crm (notif_type general(1), notif_category mention(2)), not /chat (which only accepts inbox type 3) (verified route.go:57-58, notification_handler.go:225,313). Recipient by sso_id; auth via X-Api-Key (verified flexible_auth_middleware.go:14-40). It fans out to the web Notification Center and to mobile FCM automatically for crm-origin notifications (verified service.go:47-49).
  • qontak-unified-component (Vue 3, pnpm workspace) — the web Notification Center (NotificationCenter.vue), imported into the Qontak Chat FE hosts hub/hub-chat navbar (verified hub-chat/layouts/components/TheNavbar/TheNavbar.vue:79). It already supports the mention category and renders CRM notifications; CRM-origin notifications navigate via click_action_url (verified hub-chat/layouts/composables/useNotificationCenter.ts:35-44).
  • mobile-qontak-crm (Flutter, melos) — the only mobile app that owns a dedicated customer/CDP page with a notes screen + mention compose button (verified crm_note/.../note_screen.dart, mention_toolbar_botton.dart). It receives crm-origin push (One Notification V2). Align its emitted anchor to the SSO UUID, render a graceful fallback, and route the mention notification. The CHG-004 preview card is deferred on mobile — v1 renders mentions as plain text with no tappable chip (Decision 7), so there is no target to tap; the mobile card ships with the deferred mobile chip (Decision 14).
  • mobile-qontak-chatout of scope (no CDP customer-notes feature; its notification routing is chat/room-only — verified app.dart:85-113, contact_detail_screen.dart). It must not receive CDP mention notifications.
  • qontak-launchpad (Go) — company-scoped active-user search/validation source (GET /private/users?query=&statuses[]=active&sso_ids[]=&company_sso_id=, verified user_handler.go:459, list_by_company.go:120-128). Also the preview-card detail source: the single-user lookup GET /private/users/get_by_sso_id?sso_id=LaunchpadUserDetailResponse{full_name, email, sso_id, staff_level, status, ...} (verified via contact-service qontak_launchpad.go:223-224,545-556). Caveat (grounded): that response carries no teams[] and no avatar — team(s) for the card must be resolved separately or degraded (OQ-22).

Success Criteria

  • Adoption — ≥ 30% of beta companies (flag ON) post ≥ 1 note mention within 30 days of GA (PRD §12).
  • Notification reliabilitynotify_sent / (notify_sent + notify_failed) ≥ 99% (PRD §12; observability event names §2 / §3 below).
  • Typeahead latency — mention typeahead P95 ≤ 500 ms (PRD §6).
  • Note-write latency unchanged — note create/update with mentions ≤ 2 s P95; notification dispatch is async and never blocks the write (PRD §6, ADR Decision 4).
  • Zero regression — notes without mentions behave byte-for-byte as today; existing notes unaffected.

Out of Scope

(Mirrors PRD §5 Non-Goals.)

  1. No migration of legacy CRM mentions (owned by the separate Legacy CRM Notes → CDP migration PRD).
  2. No new notification surface — reuse notification-service (POST /api/v1/notifications/crm) + the web Notification Center in qontak-unified-component (rendered in hub/hub-chat) + mobile One Notification V2 (mobile-qontak-crm). No notification UI is built in this RFC.
  3. No mention of non-users (no contacts, teams, external emails).
  4. No rich-text editor overhaul — adds the mention token only.
  5. No notification-preference management — no digest, snooze, mute, or per-user notification settings (PRD Non-Goal #5). Editing a note is allowed to add/re-mention (D-11), but a mentioned user is notified at most once per note — newly-added mentions on edit notify; re-mentioning an already-notified user does not re-notify (idempotent per (note_id, sso_id), Decision 4).
  6. No cross-company / cross-tenant mention — candidates scoped to the note's company_sso_id.
  7. No new native mobile CDP-contact route in v1 — mobile tap-through uses external_url (Decision 9 / D-10); a native route is a later enhancement. mobile-qontak-chat is entirely out of scope — it has no CDP customer-notes feature and must not receive these notifications (Decision 13).
  8. No changes to the web Notification Center component itself (qontak-unified-component) — it already renders the mention category; this RFC only emits a correctly-shaped notification and ensures the click_action_url deep-link resolves (the host-app routing gaps are tracked as OQ-17).
TitleLink / pathWhat this RFC took from it
Source PRD — Mention User in CDP Notes v1.6../prds/prd-notes-mention-user.mdAll requirements, ACs, decisions D-1…D-11, open questions OQ-1…OQ-14. (v1.3 mobile-crm-only + CTA→customer-page; v1.4 not-found semantics; v1.5 edit re-mention + idempotent notify; v1.6 CHG-004 mention preview card / story S05 + guard-rail renumber S05-NEG→S06-NEG.)
Qontak Unified Notification Servicehttps://jurnal.atlassian.net/wiki/spaces/QON/pages/49791664344Notification endpoint contract. VERIFIED 2026-06-22 against notification-service: CDP mention uses POST /api/v1/notifications/crm (not /chat); see Decision 10 + §2.4.
Notification Center on Qontak Unified Componenthttps://jurnal.atlassian.net/wiki/spaces/QON/pages/50203885766Web rendering + notif_type/notif_category taxonomy. VERIFIED 2026-06-22 against qontak-unified-component + hub/hub-chat: NotificationCenter.vue renders the mention category; see Decision 13.
RFC One Notification — Qontak Chat (mobile)https://jurnal.atlassian.net/wiki/spaces/QON/pages/50603491444Mobile tap-through routing (D-10). Mobile-side enums + routing verified in mobile-qontak-crm (§2 V-M5/6). mobile-qontak-chat confirmed out of scope (Decision 13).
CRM mention parity reference (qontak.com note.rb)Not in workspaceParity model only; UNVERIFIED — repo not in workspace. Used as conceptual precedent (PRD Appendix A), not as a code anchor here.

Assumptions

  1. VERIFIED 2026-06-22. notification-service is reachable from contact-service with a service X-Api-Key (flexible_auth_middleware.go:14-40), accepts a recipient by sso_id, and supports click_action/click_action_url deep links (notification_handler.go:67-83). The correct ingestion endpoint for a CDP mention is POST /api/v1/notifications/crm (notif_type=general(1), notif_category=mention(2)), not /chat (Decision 10). No longer a blocking unknown.
  2. VERIFIED 2026-06-22. A company-scoped active-user list/search is available. FE UserStore.getUsers() exists; contact-service validates via launchpad.IUserService; and Launchpad does expose a server-side search: GET /private/users?query=&statuses[]=active&sso_ids[]=&company_sso_id= (ILIKE on email+full_name, user_handler.go:459, list_by_company.go:120-128). OQ-6 resolved.
  3. @mekari/pixel3's MpRichTextEditor can be extended with an @ mention trigger OR a custom editor wrapper is acceptable. UNVERIFIED — pixel3 is an external package, not in the workspace (OQ-7 — feasibility spike; fallback pre-committed in Decision 12).
  4. VERIFIED 2026-06-22 — assumption corrected. The notification payload's organization_id is an optional *uuid.UUID metadata field (notification_handler.go:67-83), not the recipient and not required. The note's company_sso_id is itself a UUID (Launchpad models.go:15-27), so it can populate organization_id directly or be omitted. The earlier blocking concern (no company UUID available) is resolved (OQ-8).
  5. Plan gating (Growth + Enterprise only) and the cdp_notes_mention_enabled flag are provisioned by Ops; flag wiring is in scope, flag provisioning is not.

Dependencies

DependencyOwning teamDeliverableStatusBlocking?
notification-service POST /api/v1/notifications/crmNotification/PlatformStable endpoint + X-Api-Key; recipient by sso_id; click_action_url; notif_type=general(1)/notif_category=mention(2); organization_id optionalverified 2026-06-22 (route.go:57-58, notification_handler.go). Remaining: confirm reuse of mention category for CDP vs a new category (OQ-1, narrowed).YES (no longer a hard external blocker)
Web Notification Center (qontak-unified-component) rendered in hub/hub-chatPlatform / FE PlatformRenders mention notification; routes click_action_url for CRM originverified 2026-06-22 (NotificationCenter.vue, TheNavbar.vue:79, useNotificationCenter.ts:35-44). Remaining: host-app deep-link to notes tab + un-gate Notes tab (OQ-17).YES
qontak-customer-fe MFE embed in hub/hub-chatCDP / FE PlatformRemoteContactRouter mounted at /customers/**; ?tab=notes deep-link resolvespartial — embed verified (nuxt.config.ts:92-111, hub-chat/nuxt.config.ts:526); ?tab=notes query not parsed + Notes tab gated behind isStaging (OQ-17)YES
Mobile One Notification V2 routing (mobile-qontak-crm only)Mobile Squadexternal_url tap-through (Decision 9 / D-10); auto-FCM for crm originexists — verified routing (§2 V-M5/V-M6); mention/general enums exist; mobile-qontak-chat excluded (Decision 13)YES (mobile tap-through)
Company-scoped user search (typeahead source)Launchpad / CDPCompany active-user list with sso_id + full_name + status; ?query= search variantverified 2026-06-22 — GET /private/users?query=&statuses[]=active exists (user_handler.go:459). Caveat: avatar is not in the list response.YES
Preview-card user-detail source (CHG-004 / S05)Launchpad / CDPSingle-user detail by sso_id with full_name + email + staff_level + status + teams[] for the cardpartial — single-user detail GET /private/users/get_by_sso_id + LaunchpadUserDetailResponse verified (qontak_launchpad.go:223-224,545-556) and carries name/email/staff_level/status, but teams[] and avatar are absent → team resolution is the gap (OQ-22; degraded fallback ships without teams)YES (S05 web only; mobile deferred)
qontak-customer-fe preview card (CHG-004 / S05)CDP / FEMpPopover hover card on the rendered chip + UserStore.getUserDetail(ssoId) + inactive/not-found stateneeds-building (this RFC); design exists (Figma 15091-119388 / 15091-448947) so not gated on OQ-14; reuses verified MpPopover (NotesList.vue:31-46)YES (web)
contact-service note schema + parser + sanitizerCDP Squadmentioned_user_ids field, dual-form anchor parser (D-9), server-side HTML sanitization, async dispatchneeds-building (this RFC)YES
qontak-customer-fe composer + rendererCDP / FETypeahead (OQ-7 spike), DOMPurify allow-list (save+render), mention chipneeds-building (this RFC; OQ-7 blocking)YES
Tiptap mention editor (FE fallback — Decision 12)CDP / FE@tiptap/vue-3 + @tiptap/extension-mention (+ Vue-3 bindings), only if the OQ-7 spike rules out a pixel3-native extensionconditional (new npm dep; see §3 bundle budget — REV-11)NO (fallback path)
mobile-qontak-crm anchor alignment + render + routeMobile / CDPEmit SSO-UUID anchor (Decision 8), graceful render (OQ-11), external_url route (Decision 9)needs-building (this RFC)YES (mobile parity)

Design References (frontend + mobile half — required)

PRD-named surfaceFigma / design linkFrame nameDesign system versionDesign QA contactNotes
Web — mention typeahead pickern/a — design pendingTBD@mekari/pixel3@1.0.10-dev.0 (verified package.json:24)TBDBlocker — no Figma yet (PRD: "Figma TBD"). Do not implement the picker against an imagined design (OQ-7 also gates this).
Web — mention chip (rendered note)n/a — design pendingTBD@mekari/pixel3@1.0.10-dev.0TBDChip = styled non-editable @Name. Token set TBD.
Web — mention preview card (CHG-004 / S05)Figma 15091-119388preview card (name/email/staff level/team(s))@mekari/pixel3@1.0.10-dev.0 (reuse MpPopover)TBDDesign exists — preview card on hover; reuses MpPopover (NotesList.vue:31-46). Not gated on OQ-14.
Web — preview card inactive / not-found stateFigma 15091-448947inactive / not-found card@mekari/pixel3@1.0.10-dev.0TBDLaunchpad generic 404 (deleted or non-existent) → inactive-avatar + last-known name; never errors the note view (same constraint as OQ-20).
Mobile — mention preview cardn/a — deferred (v1)mobile DSDeferred with the mobile chip (Decision 7 / 14) — no tappable target in v1.
Mobile — mention picker (bottom sheet)n/a — already shipsexisting CDP member pickermobile DS (Quill toolbar)TBDCompose UI already ships (MpMentionToolbarButtonX); no new design needed for compose.
Mobile — mention display in note listn/a — design pendingTBDmobile DSTBDv1 = graceful plain-text fallback (Decision 7 / OQ-11); chip deferred.
Web + mobile — notification surfaceN/A — uses Notification Center (web) / One Notification V2 (mobile)No new notification UI (Non-Goal 2).

Every "design pending" surface is a §5 Open Question. Frontend composer chunks (§4.D order 4) must not start until the Figma frame exists and OQ-7 is resolved.

PRD-to-Schema Derivation (backend half — required)

contact-service persists notes in MongoDB (verified: go.mongodb.org/mongo-driver, db/migrations/*.json). "Schema" here is the contact_notes document shape (Go struct + bson tags), not SQL DDL.

PRD-described entity / attribute / rulePersisted as (collection.field)Exposed via (endpoint / event)Enforced whereSource
A note may mention one or more company users (D-1)contact_notes.mentioned_user_ids []string (SSO UUIDs), bson:"mentioned_user_ids,omitempty"Existing POST/PUT /iag/v1/contacts/{contact_id}/notes (extended); response ContactNoteResponse.mentioned_user_idsContactNotesService.CreateNote/UpdateNote (parse + persist)PRD §7 CHG-003
Mention anchors appear in two encodings — web data-user-id + mobile href=".../users/{id}/edit_user" (D-9)(parsed from contact_notes.note HTML)same write endpointsnew mention.Parser invoked in service before persistPRD §6, §8#2, D-9
Every mentioned SSO UUID must be an active user in the note's company (S04)not stored as a flag; validated at writerejected/dropped per Decision 5 (drop-and-warn)service → launchpad.IUserService company-user lookupPRD §9 S04, §8#2
Note content ≤ 10,000 chars including mention markupcontact_notes.note string, validate:"required,max=10000" (verified base.go:29)400/422 on overflowvalidator.Struct(req) (handler) + if len(req.Note) > 10000 (contact_notes_service.go:272)PRD §6, §8#2
Max mentions per note = 10 (Decision 6 / OQ-4)derived from parsed count422 if exceeded (or drop-extras — Decision 6)new check in mention.Parser/servicePRD OQ-4
Notify on create and edit, idempotently — each user at most once per note (D-4/D-11)append-only contact_notes.notified_user_ids []string (SSO UUIDs already notified), bson:"notified_user_ids,omitempty"async job → Notification Serviceservice computes to_notify = parsed_valid − notified_user_ids − self; enqueues per id; appends them to notified_user_idsPRD §7 CHG-003, D-4, D-11
A removed-then-re-added mention is not re-notified (OQ-14)persisted in notified_user_ids (never pruned)n/athe notified set is append-only, so a re-added id is already present → skippedPRD OQ-14
Author never self-notified (S03 ERR-2)n/an/aservice filters author_sso_id out of recipientsPRD §9 S03 ERR-2
Company identity for notification organization_idcontact_notes.company_sso_id (verified base.go:29) → Launchpad company.id UUIDnotification payloadmapping/lookup at dispatch (OQ-8)PRD OQ-8
Preview card resolves a mentioned user's identity (CHG-004 / S05)no new persisted field — reads the existing contact_notes.mentioned_user_ids (SSO UUIDs) and resolves liveLaunchpad single-user detail GET /private/users/get_by_sso_id (FE-direct via UserStore.getUserDetail) — not the note write pathFE preview card (lazy on hover); Decision 14PRD §7 CHG-004, §9 S05

Every row in §2.3 (document model) and every endpoint in §2.4 traces back to a row here or to a Design Reference frame. No inferred fields.

Detail 1.A — PRD Traceability (cross-layer)

Composite AC ids are the PRD's Section-9 story-qualified ids (<STORY-ID>/AC-n).

Forward (PRD AC → RFC):

PRD composite AC idFE section / componentBE section / endpoint
NOTE-MENTION-S01/AC-1 (typeahead ≤500ms)§2.A NoteInput.vue typeahead; §2.B fetch§2.4 user-search (reused)
NOTE-MENTION-S01/AC-2 (chip carries SSO UUID)§2.A mention chip + anchorn/a
NOTE-MENTION-S01/AC-3 (persist + mentioned_user_ids)§2.A save path§2.4 POST …/notes (extended); §2.3 doc model
NOTE-MENTION-S01/AC-4 (edit: @ enabled; re-mention + add; idempotent notify — D-11)§2.A composer enabled on edit§2.4 PUT …/notes; §2.F notified-set (Decision 4)
NOTE-MENTION-S01/ERR-1 (picker fail)§2.C UI state · §3.C error catalogn/a
NOTE-MENTION-S01/ERR-2 (typo or deleted → "No matching people", generic 404 — v1.4)§2.C empty state · §3.C error catalog§2.4 user-search (reused); OQ-20
NOTE-MENTION-S02/AC-1 (mobile anchor → SSO UUID)§2.A mobile (Decision 8)§2.4 parser accepts href form (D-9)
NOTE-MENTION-S02/AC-2 (mobile persist == web)mobile save§2.4 POST …/notes
NOTE-MENTION-S02/AC-3 (mobile render, no raw HTML)§2.A mobile render (Decision 7)n/a
NOTE-MENTION-S02/AC-4 (mobile edit: button enabled; re-mention + add; idempotent — D-11)§2.A mobile compose on edit§2.4 PUT …/notes; §2.F notified-set (Decision 4)
NOTE-MENTION-S02/ERR-1 (offline picker)§2.C mobile UI staten/a
NOTE-MENTION-S03/AC-1 (dispatch per not-yet-notified mention, async)n/a§2.F async job; §2.2 sequence
NOTE-MENTION-S03/AC-2 (web tap → notes; mark read)unified Notification Center (qontak-unified in hub/hub-chat)n/a
NOTE-MENTION-S03/AC-2b (mobile tap via external_url)mobile route (Decision 9)dispatch sets origin=external_url
NOTE-MENTION-S03/AC-3 (edit notifies newly-added only; re-mention of already-notified → no re-notify; ≤1/note — D-11)n/a§2.F notified-set logic (Decision 4)
NOTE-MENTION-S03/ERR-1 (notify 5xx → retry×3 → log)n/a§2.F retry; §3.A failure catalog
NOTE-MENTION-S03/ERR-2 (no self-notify)n/aservice recipient filter
NOTE-MENTION-S04/AC-1 (valid mentions stored + notified)n/a§2.4 validation
NOTE-MENTION-S04/ERR-1 (invalid SSO UUID → drop-and-warn)§3.C warn chip§2.4 validation (Decision 5)
NOTE-MENTION-S04/ERR-2 (crafted anchor → server sanitize)n/a§3 server-side sanitization (Decision 3)
NOTE-MENTION-S04/ERR-3 (deleted/unresolvable SSO UUID → generic 404 → drop/reject; same path as ERR-1 — v1.4)§3.C warn chip§2.4 validation (Decision 5); OQ-20
NOTE-MENTION-S05/AC-1 (hover web / tap mobile chip → card: name/email/staff level/team(s))§2.A preview card; §2.B fetch§2.4 user-detail lookup (reused — Launchpad get_by_sso_id)
NOTE-MENTION-S05/AC-2 (multiple teams all listed)§2.A card teams rowOQ-22 (teams not on detail response — resolve or degrade)
NOTE-MENTION-S05/AC-3 (dismiss → card closes, note readable)§2.A card open/close · §2.C UI staten/a
NOTE-MENTION-S05/ERR-1 (inactive/deleted → inactive/not-found card, no error)§2.A inactive state · §2.C · §3.C§2.4 detail (generic 404 — OQ-20)
NOTE-MENTION-S05/ERR-2 (lookup 5xx/timeout → graceful fallback, note readable)§2.A card error · §2.C · §3.C§2.4 detail (timeout)
NOTE-MENTION-S06-NEG/NEG-1 (cross-company rejected)n/a§2.4 company-scoped validation
NOTE-MENTION-S06-NEG/NEG-2 (flag OFF → no picker, today's behavior)§4 flag gate§4 flag gate

Reverse (RFC → PRD AC):

New FE component / BE artifact / dependencyPRD composite AC id it serves
mention.Parser (dual-form, D-9)NOTE-MENTION-S01/AC-3, S02/AC-2, S04/AC-1
mentioned_user_ids field (current mentions)S01/AC-3, S02/AC-2
notified_user_ids append-only set + idempotent dispatch (Decision 4)S01/AC-4, S02/AC-4, S03/AC-3
Mention-validation via IUserServiceS04/AC-1, S04/ERR-1, S06-NEG/NEG-1
FE preview card (MpPopover) + UserStore.getUserDetail(ssoId) + inactive/not-found state (Decision 14)S05/AC-1, S05/AC-2, S05/AC-3, S05/ERR-1, S05/ERR-2
Server-side HTML sanitizer (bluemonday)S04/ERR-2
Async MentionNotifyJob (gocraft/work)S03/AC-1, S03/ERR-1
FE typeahead + chip + DOMPurify ADD_ATTR (save+render)S01/AC-1, S01/AC-2, S01/ERR-1
Mobile CDP mapper → ssoId anchor (Decision 8)S02/AC-1
Mobile external_url notification route (Decision 9)S03/AC-2b

UI / Consumer Surface Coverage

PRD-named surfaceConsumerRequired reads (BE)Required writes (BE)FE componentStatus surface
Note composer (web)webcompany user search (typeahead)POST/PUT /iag/v1/contacts/{id}/notes (extended)NoteInput.vuementioned_user_ids echoed in note response
Notes list (web)webGET /iag/v1/contacts/{id}/notes (existing)n/aNotesList.vuemention chip rendered from stored anchor
Mention preview card (web — CHG-004/S05)webLaunchpad single-user detail get_by_sso_id (FE-direct, lazy on hover)n/a (read-only)NotesList.vue chip + MpPopover cardname/email/staff level/team(s); inactive/not-found state
Mention preview card (mobile)mobilen/a — deferred v1n/an/a — no chip in v1 (Decision 7)deferred with the mobile chip (Decision 14)
Note composer (mobile)mobileCDP member picker (GetListMemberCdp, existing)POST/PUT …/notesdetail_note_screen.dart + mention_toolbar_botton.dartmentioned_user_ids
Note list (mobile)mobileGET …/notes (existing)n/anote_screen.dart / note_item.dartplain-text fallback (Decision 7)
Notification (web)webNotification Center reads (/notif/v1/notifications)n/a — covered by dispatchNotificationCenter.vue (qontak-unified-component in hub/hub-chat)notif_category=mention(2), origin=crm
Notification (mobile)mobileOne Notification V2 readsn/aNotificationV2Screen (mobile-qontak-crm only)origin=external_url tap-through (Decision 9)

Role Coverage

PRD roleAuthorization mechanismEndpoints permitted (BE)UI surface visibility (FE)Cross-tenant?Audit trail
CS/Sales Agent (author)chi RequirePermissionMiddleware + perm keycreate: customers_customernotes_add; update: customers_customernotes_manage (verified rest_router.go:154,157)composer @ affordance shown when flag ON + has create/manageno (company-scoped)note write logged; cdp_note_mention_added
Mentioned teammate (recipient)own notification stream (recipient sso_id)n/a (reads own notifications, ext service)sees notification; can view note if customers_customernotes_viewnocdp_note_mention_notify_sent
Viewer (read-only)customers_customernotes_view (verified rest_router.go:153)GET …/notessees mention chip + preview card on hover (CHG-004/S05 — company-scoped directory data only); no @ affordancenon/a
System (server-side validation)service-internalvalidation + dispatchn/aenforces company scopecdp_note_mention_invalid

PRD Section Coverage

PRD §TitleWhere covered
2Adjustment Context§1 Overview
3One-liner + Problem§1 Overview
4Target Users + Persona§1.A Role Coverage
5Non-Goals§1 Out of Scope
6Constraints§2 Technical Decisions, §3 HA/Security, §4 Config
7Feature Changes (CHG-001/002/003/004)§2.1–§2.4, §2.A, §2.F; CHG-004 preview card → Decision 14, §2.4 user-detail, §2.A, §4.D ch.11/12
8API & Webhook Behavior§2.4 APIs, §2.2 sequences, §3.A failure
9System Flow + Stories + ACs§1.A traceability, §1.C per-story map, §2.2
10Rollout§4 Rollout
11Observability§3 Monitoring & Alerting
12Success Metrics§1 Success Criteria
13Dependencies§1 Dependencies, §2.F.1 responsibility
14Key Decisions + Alternatives§2 Technical Decisions (ADR) + §1.B
15Open Questions§5 Open Questions (OQ-1…OQ-12)
Appendix AGrounded Code References§2 Source Verification (re-verified)

Detail 1.B — Decisions Closed (cross-layer index)

Full ADR treatment in §2 Technical Decisions. Decision numbers below are RFC-local; the mapping to PRD decision ids (D-n) is noted.

#DecisionChosen optionLayer§2 block
1Mention storage shapeResolved tag mentioned_user_ids []string (SSO UUID) on the Mongo note doc (D-1)BEDecision 1
2Sync vs async notificationAsync via existing IJobEnqueuer worker (gocraft/work); non-blocking (D-4, D-8)BEDecision 2
3Server-side sanitizationAdd bluemonday allow-list on the note write path (D-5); new-with-justificationBEDecision 3
4Re-notification policyNotify on create and edit, idempotent per (note_id, sso_id) via append-only notified_user_ids; ≤1/note; remove→re-add not re-notified (D-4, D-11, OQ-14)BEDecision 4
5Invalid mention handlingDrop-and-warn (note saves; invalid id excluded) (D-3 / OQ-3)BE+FEDecision 5
6Max mentions per note10 (OQ-4)BEDecision 6
7Mobile renderGraceful plain-text fallback for v1 (D-? / OQ-11)MobileDecision 7
8Mobile mention encodingChange the CDP mapper to emit sso_id in the href; backend dual-parses (D-9 + refined OQ-10/OQ-12)Mobile+BEDecision 8
9Mobile notification routeexternal_url + web click_action_url for v1 (D-10 / OQ-5); mobile-qontak-crm onlyMobileDecision 9
10Notification delivery channel + contractReuse notification-service via new outbound client; POST /api/v1/notifications/crm (type general(1), category mention(2)); recipient sso_id; no skip_fcm; organization_id optional (verified 2026-06-22)BEDecision 10
11Typeahead sourceReuse existing company-user list; client-side filter; Launchpad ?query= search now verified available (OQ-6 resolved)FEDecision 11
12Web editor mention triggerPartial — fallback = custom Tiptap editor; OQ-7 spike only decides pixel3-native vs TiptapFEDecision 12
13Web rendering + mobile app scopingWeb mention renders in the unified NotificationCenter (qontak-unified-component) hosted in hub/hub-chat; CDP page embedded as MFE; mobile delivery is mobile-qontak-crm only (mobile-qontak-chat excluded)FE+MobileDecision 13
14Preview-card data source (CHG-004/S05)FE resolves the chip's sso_id lazily on hover via a new UserStore.getUserDetail → Launchpad get_by_sso_id (verified detail response), client-cached; inactive/not-found = generic-404 state; no note-read-path change; mobile card deferred with the mobile chip (Decision 7)FEDecision 14

Minimum-coverage confirmation (full-stack): per-status lifecycle — n/a — notes have no status enum (verified: ContactNote has is_deleted soft-delete bool only, no status field, base.go:26-36); soft-delete vs hard-delete — existing soft-delete (is_deleted) unchanged; cross-squad responsibility — §2.F.1; inbound webhook ownership — n/a — no inbound webhook (notification is outbound only); opt-out/skip ownership — §3.A.1; reuse-vs-new — §2.0 contracts table.

Detail 1.C — Per-Story Change Map

Story idTitleLayer scopeFE changesBE changesComposite AC idsAcceptance criteria (verifiable)RFC anchors
NOTE-MENTION-S01Mention a teammate (Web)FE + BENoteInput.vue @ typeahead (OQ-7 spike); insert mention anchor (data-user-id+data-mention); @ affordance enabled on edit (D-11); DOMPurify ADD_ATTR on save (NoteInput.vue:460) and render (NotesList.vue:104); chip CSSmention.Parser (web form); persist mentioned_user_ids; echo in response; idempotent notify via notified_user_ids (Decision 4)S01/AC-1, AC-2, AC-3, AC-4, ERR-1, ERR-2vitest: chip renders + anchor carries sso_id; edit re-opens with @ enabled; Go test: CreateNote/UpdateNote store parsed ids; typeahead P95 ≤500ms (load); empty/404 query → "No matching people"§2.A · §2.4 · §2.3 · §4.D ch.4,6,7
NOTE-MENTION-S02Mention a teammate (Mobile)FE + BEmention_toolbar_botton.dart CDP mapper emits sso_id in href (_mapMemberToMention, l.398-402); mention button enabled on edit (D-11); note-list render fallback (note_screen.dart)mention.Parser accepts mobile href form (D-9) → normalizes to SSO UUID; idempotent notify via notified_user_idsS02/AC-1, AC-2, AC-3, AC-4, ERR-1Dart test: emitted href contains ssoId; edit keeps button enabled; Go test: href form parsed to same mentioned_user_ids; list never shows raw HTML§2.A · §2.4 · §4.D ch.5,8
NOTE-MENTION-S03Mentioned user notified (Web & Mobile)FE + BEmobile: ensure external_url route opens click_action_url (existing path, verified)MentionNotifyJob (gocraft/work) per not-yet-notified mention (create and edit); to_notify = parsed − notified_user_ids − self; append to notified_user_ids; retry×3 backoff; organization_id mapS03/AC-1, AC-2, AC-2b, AC-3, ERR-1, ERR-2Go test: job enqueued per not-yet-notified recipient only (create + edit); no enqueue for already-notified/removed-then-re-added/self; user notified ≤1/note; on 5xx retries then logs cdp_note_mention_notify_failed, note stays saved§2.2 · §2.F · §2.F.1 · §3.A · §4.D ch.3,9
NOTE-MENTION-S04Only valid company users mentionableBEn/a — BE-only (FE shows warn chip on drop)validate each SSO UUID via IUserService (company-scoped); drop-and-warn; treat Launchpad generic 404 (deleted or non-existent) as not-active (ERR-3); server-side sanitize crafted anchorsS04/AC-1, ERR-1, ERR-2, ERR-3Go test: cross-company id dropped + cdp_note_mention_invalid logged; deleted/unresolvable id (404) dropped same as invalid; crafted <script>/extra attrs stripped by sanitizer§2.4 · §3 Security · §4.D ch.2,3
NOTE-MENTION-S05Preview a mentioned user on the notes list (Web; mobile deferred)FEweb: NotesList.vue chip becomes a hover target wrapped in MpPopover (reuse :31-46); new preview-card component; UserStore.getUserDetail(ssoId); inactive/not-found + lookup-error states; teams resolved or degraded (OQ-22). mobile: deferred — no tappable chip in v1 (Decision 7)n/a — FE-only (reads the existing mentioned_user_ids; Launchpad detail is FE-direct, no write-path change)S05/AC-1, AC-2, AC-3, ERR-1, ERR-2vitest: hover resolves + renders name/email/staff level/team(s); deleted/unresolved (404) → inactive state, no throw; 5xx → graceful fallback; dismiss leaves note readable§2.A · §2.4 · §4.D ch.11,12
NOTE-MENTION-S06-NEGNo cross-company / non-user mentions (guard rail)FE + BEflag OFF → no @ affordanceflag OFF → no parsing/validation/dispatch; cross-company id rejectedS06-NEG/NEG-1, NEG-2Go test: flag OFF path == today's behavior; cross-tenant id never notified§4 flag · §2.4

2. Technical Design

Infrastructure Topology

Deployment topology

flowchart TB
agent([Agent browser / mobile app]) -->|HTTPS| lb[Ingress / API Gateway]
lb -->|HTTP| cs["contact-service-api pods\n(chi, stateless)"]
cs -->|read / write bson| mongo[("MongoDB\n(contact_notes collection)")]
cs -->|GET cache?| redis[("Redis\n(gocraft/work queue + cache)")]
cs -->|enqueue MentionNotifyJob| redis
redis -->|consume| worker["contact-service-worker pods\n(gocraft/work)"]
worker -->|"HTTPS POST /api/v1/notifications/crm\nX-Api-Key (crm origin)"| ns(["notification-service\n(Notification/Platform)"])
cs -->|HTTPS user lookup / validate| lp(["qontak-launchpad\n(GET /private/users)"])
ns -->|"web (origin=crm)"| nc(["Notification Center\n(qontak-unified-component\nin hub/hub-chat navbar)"])
ns -->|"FCM (crm whitelist, auto)"| fcm(["One Notification V2\n(mobile-qontak-crm only)"])
nc -->|"click → click_action_url\n/customers/{id}?tab=notes"| mfe(["qontak-customer-fe MFE\n(RemoteContactRouter)"])

Verified infra: MongoDB driver (go.mongodb.org/mongo-driver v1.12.1, go.mod); Redis-backed gocraft/work worker (internal/app/service/job_enqueuer.go:45-83, make run-worker Makefile:64-77); existing Launchpad HTTP client (internal/app/api/qontak_launchpad.go:38). All downstream nodes verified 2026-06-22: notification-service (route.go:57-58), web Notification Center (qontak-unified-component NotificationCenter.vue; imported hub-chat/.../TheNavbar.vue:79), MFE embed (hub-chat/nuxt.config.ts:526), mobile (mobile-qontak-crm).

Per-service responsibility

flowchart LR
subgraph cs["contact-service (CDP, owner: CDP Squad)"]
ep1["POST/PUT /iag/v1/contacts/{id}/notes\n(parse+validate+sanitize+persist)"]
parser["mention.Parser (dual-form, D-9)"]
job["MentionNotifyJob (async)"]
end
ep1 --> parser
ep1 -->|"in-process validate"| ius["launchpad.IUserService\n(company-user lookup)"]
ep1 -->|"enqueue (Redis)"| job
ius -->|"HTTPS (heimdall)"| lp(["Launchpad (owner: Launchpad)"])
job -->|"HTTPS POST /api/v1/notifications/crm (heimdall, X-Api-Key)"| ns(["notification-service\n(owner: Notification/Platform)"])

Technical Decisions (ADR-format — engineering heart)

Decision 1: Mention storage as mentioned_user_ids []string on the Mongo note document

Context. The note record lives in MongoDB (contact_notes collection; verified base.go:26-36, migration db/migrations/013_create_contact_notes.up.json). We need a stable, queryable key for notification fan-out and any future "notes mentioning me" view (PRD D-1, rejected alternative "resolve names live only").

Options considered.

  • Option A — denormalized mentioned_user_ids []string on the note doc.
    • Pros: single-document write (atomic with the note in Mongo); trivially queryable; mirrors the proven CRM model with CDP identity; no join.
    • Cons: must keep in sync with the parsed anchors on every write.
  • Option B — separate note_mentions collection (note_id, sso_id).
    • Pros: normalized; easy per-mention metadata later.
    • Cons: a second write not atomic with the note write in Mongo (no multi-doc transaction wired today); over-engineered for a fan-out list.

Decision. Option A — add MentionedUserIDs []string \bson:"mentioned_user_ids,omitempty"`to theContactNote` struct.

Rationale. The note write is a single mongo.Create/UpdateNote today (verified repository/contact_notes/create.go:10, update.go:16); embedding the array keeps the write atomic and adds no new collection or transaction machinery. Maps directly to PRD D-1.

Consequences. A document migration adds the field (schemaless — old docs simply lack it → treated as empty). A companion append-only notified_user_ids []string is added alongside for idempotent dispatch (Decision 4) — mentioned_user_ids is the current mention set (for rendering), notified_user_ids is the lifetime notified set (for dedup). If a future "mentions me" inbox needs an index, add a db/migrations/0NN_contact_notes_mention_index.up.json on mentioned_user_ids (not needed for v1 fan-out, which reads the just-written doc).

Reversibility. Drop the field from the struct; existing docs ignore it. Low cost.


Decision 2: Async, non-blocking notification dispatch via the existing gocraft/work worker

Context. The notes write path is fully synchronous with zero side effects today (verified: grep for go func|kafka|Publish|producer|async in service/contact_notes/, handler/contact_notes_handler.go, repository/contact_notes/zero hits). Notification dispatch must not block the ≤2s note write (PRD §6) and must survive Notification-Service flakiness (PRD D-4, D-8).

Options considered.

  • Option A — in-process fire-and-forget goroutine with backoff.
    • Pros: no infra; simplest code.
    • Cons: lost on pod restart/crash; no durable retry; retries die with the request; no visibility — exactly the partial-failure risk the PRD calls out.
  • Option B — enqueue a MentionNotifyJob on the existing IJobEnqueuer (gocraft/work + Redis), consumed by the worker pod.
    • Pros: durable retry; survives restarts; OTel trace propagation already built in (job_enqueuer.go:59-71); reuses a verified in-repo pattern (contact_sync_handler.go:142,178 enqueues jobs today); separate worker process keeps the API pod hot.
    • Cons: requires the worker deployment to run (it already does — make run-worker); one enqueue per recipient.

Decision. Option B — after the note write commits, enqueue one MentionNotifyJob per newly-added mention via IJobEnqueuer.EnqueueJob.

Rationale. The repo already standardizes async on gocraft/work; reusing it gives durable retries and tracing for free and matches the "net-new async pattern, design it properly" scope the PRD demanded (D-8). A bare goroutine would silently drop notifications on deploy — unacceptable against the ≥99% reliability target.

Consequences. The worker must have the Notification-Service client + X-Api-Key configured. Per-job retry/backoff and final-failure logging live in the job handler (§2.F). Enqueue is best-effort: if Redis is down, log cdp_note_mention_notify_failed with reason enqueue_failed — the note still saves.

Reversibility. Swap the job for a synchronous call behind the same service method; the call site is one function. Low cost.


Decision 3: Server-side HTML sanitization with bluemonday (allow-list mention anchors)

Context. Today the note HTML is stored unsanitized server-side (verified: no sanitizer in the notes path; the FE sanitizes with DOMPurify only — option-less — at both NoteInput.vue:460 save and NotesList.vue:104 render). Storing attacker-controllable HTML that carries a data-user-id mention anchor without server-side sanitization is an XSS sink (PRD D-5, S04/ERR-2).

Options considered.

  • Option A — trust FE DOMPurify only.
    • Pros: no BE change.
    • Cons: client is not authoritative (mobile + API callers bypass it); fails the security requirement; PRD explicitly rejects this.
  • Option B — server-side sanitize on write with github.com/microcosm-cc/bluemonday (no in-repo sanitizer exists — new-with-justification).
    • Pros: authoritative; allow-lists exactly the mention anchor (a[data-user-id][data-mention] + safe formatting tags) and strips everything else; well-maintained Go library.
    • Cons: new dependency; must define the allow-list policy to match what the editor emits without breaking existing formatting.

Decision. Option B — add bluemonday, build a notePolicy allow-list, and run it in the service before persist (both create and update).

Rationale. The server is the only trust boundary all three clients cross. No in-repo HTML sanitizer exists, so this is new-with-justification. The policy mirrors the existing toolbar capabilities (heading/bold/italic/underline/strike/lists/links, verified NoteInput.vue:155-166) plus the mention anchor attributes.

Concrete notePolicy spec (the allow-list must be this strict — an unbounded href policy is a stored-XSS sink):

  • Base: bluemonday.UGCPolicy()-style — allow p,br,strong,em,b,i,u,s,h1..h3,ul,ol,li,a.
  • a attributes: allow href and data-user-id + data-mention only; strip target, rel, style, class, on*.
  • href scheme: AllowURLSchemes("https") for normal links; additionally allow the relative mention form ^\.\./.*\/edit_user$ (mobile anchor); reject javascript:, data:, and all other schemes.
  • data-user-id value must match a UUID pattern; non-UUID → the attribute (and the mention) is dropped (ties to Decision 5).
  • Strip every tag/attribute not on the list (default-deny).

Consequences. New dependency in go.mod; the allow-list must be reviewed and signed off by infosec (approver gate). Over-strict policy could drop legitimate formatting — covered by a golden-file test of representative note HTML that asserts both (a) allow-listed formatting + the mention anchor survive and (b) <script>, onerror=, javascript: href, style=, and extra attributes are stripped.

Reversibility. Remove the sanitizer call; revert the dependency. Medium cost (the field stays; only the write-time transform is removed).


Decision 4: Notify on create AND edit, idempotently — append-only persisted notified set keyed (note_id, sso_id)

Context (PRD v1.5 — D-4, D-11, OQ-14). Editing a note keeps the mention affordance enabled (re-mention + add allowed — D-11, reverses the transient v1.3 "freeze on edit"), and notification must fire on both create and edit, but each user is notified at most once per note lifetime — exactly the CRM model (app/models/crm/note.rb: Crm::Notification.find_or_create_by on (note, user, Email) + a sent_at guard fires after_commit on create and update, deduping re-mentions). Critically, the dedup is append-only and persists across edits: a mention that is removed and later re-added is not re-notified (the Crm::Notification row persists — PRD OQ-14). UpdateNote already loads the existing note first (verified contact_notes_service.go:184 GetNoteByID), so the prior state is available without an extra read.

Options considered.

  • Option A — notify every parsed mention on every save. Pros: trivial. Cons: duplicate notification spam on every edit — rejected by PRD.
  • Option B — diff against the currently-stored mentioned_user_ids (new = parsed − previous_mentions). Pros: one set diff, no new field. Cons: wrong for OQ-14 — a removed-then-re-added mention is absent from previous_mentions, so it would be re-notified. Does not give "at most once per note lifetime."
  • Option C — diff against a separate, append-only persisted notified set (notified_user_ids). to_notify = parsed_valid − notified_user_ids − self; enqueue per id; then append the enqueued ids to notified_user_ids (never pruned, even when the mention is removed). Pros: faithful to CRM + OQ-14; a removed-then-re-added id is already in the set → skipped; survives any number of edits; queryable. Cons: one extra []string field on the doc (cheap — Decision 1's denormalized-on-the-doc philosophy).

Decision. Option C — add an append-only notified_user_ids []string to the note doc. On every save (create or edit): to_notify = parsed_valid − notified_user_ids − self; enqueue one MentionNotifyJob per id in to_notify; in the same write, set notified_user_ids = notified_user_ids ∪ to_notify. mentioned_user_ids (current mentions, for rendering / future "mentions me") and notified_user_ids (lifetime notified set, for dedup) are distinct fields.

Rationale. Only an append-only persisted set delivers "≤1 notification per note per user across its whole edit history" without re-notifying on remove→re-add (OQ-14). The currently-stored-set diff (Option B) silently violates this. The set is written atomically with the note (single Mongo doc — Decision 1), so the notified-set update and the note body commit together. Self-mentions are filtered out of to_notify (S03/ERR-2) and never enter the set. The per-job idempotency key (note_id + recipient_sso_id, §2.D/§2.F) remains as a second-line guard against the concurrent-PUT race where two saves both read the set before either appends; the persisted set is the primary guarantee.

Consequences. Create path: notified_user_ids = ∅, so all valid parsed mentions (minus self) notify and seed the set. Edit path: only ids not already in the set notify. Removed mentions get no "un-notify" (none exists — Decision 10 / D-6) and stay in the set so they are not re-notified later. Old notes lack the field → treated as ; the first edit of a legacy note seeds the set from its then-current mentions (an old mention may notify once on that first post-feature edit — accepted, documented as a known limitation).

Reversibility. Drop notified_user_ids and fall back to the Option-B diff; existing docs ignore the field. Low cost (behavioral, not data-destructive).


Decision 5: Invalid mention → drop-and-warn (note still saves)

Context. A mention SSO UUID may not be an active company user (typo, deactivated user, cross-tenant). Reject-whole-save vs drop-the-mention (PRD OQ-3, recommended drop).

Options considered.

  • Option A — 422, reject the whole note. Pros: strict. Cons: a single bad id blocks saving an otherwise good note — poor UX.
  • Option B — drop the invalid id from mentioned_user_ids, save the note, surface a warning to the FE. Pros: resilient; matches PRD recommendation.

Decision. Option B — drop invalid ids, persist the valid set, return the dropped ids in the response so the FE can show a warn chip; log cdp_note_mention_invalid with action=dropped.

Rationale. PRD recommendation; a typo must not block a note. The stored HTML anchor for a dropped id is sanitized to plain text (no dangling identity).

Consequences. Response schema gains an optional dropped_mentions array. Reversibility. Flip to 422 by changing one branch (config-guardable).


Decision 6: Max 10 mentions per note

Context. Bound notification fan-out and stay within the 10,000-char limit (each web anchor ≈ 80 chars; verified limit contact_notes_service.go:272). PRD OQ-4 recommends 10.

Decision. Cap at 10 distinct mentions per note; on overflow return 422 TOO_MANY_MENTIONS.

Rationale. 10 × ~80 chars ≈ 800 chars — well within budget; bounds fan-out. No alternative seriously considered beyond the cap value — the value is a tunable constant.

Consequences. A const MaxMentionsPerNote = 10; surfaced in the error catalog. Reversibility. Change the constant.


Decision 7: Mobile renders mentions as graceful plain text in v1

Context. mobile-qontak-crm has no HTML renderer in crm_note (verified: no flutter_html/markdown dependency; list renders (noteItem.note ?? '').stripHtml() at note_screen.dart:307 (CDP) / :336 (CRM) via plain Text note_item.dart:51). A styled chip would require introducing an HTML renderer (PRD OQ-11).

Options considered.

  • Option A — add flutter_html (or similar) + chip widget. Pros: visual parity. Cons: net-new renderer, larger scope/bundle, risk to existing note display.
  • Option B — graceful plain-text fallback (@Name), defer chip. Pros: zero render-stack change; never shows raw HTML; satisfies S02/AC-3.

Decision. Option B for v1.

Rationale. Compose + persist + notify are the user value; a styled mobile chip is cosmetic and gated on a new render stack. stripHtml() already yields @Name cleanly.

Consequences. Mobile mention looks like plain @Name in the list and a plain link in the reopened Quill editor (data-* lost on round-trip — verified). Chip is a follow-up.

Reversibility. Additive — introduce a renderer later without changing the data.


Decision 8: Mobile emits the user's sso_id in the mention href; backend dual-parses (resolves OQ-10 + OQ-12)

Context (grounded). The mobile mention button already ships (MpMentionToolbarButtonX(isCdp:true), verified detail_note_screen.dart:871-876) and inserts a Quill link '../../../users/${mention.id}/edit_user' (verified mention_toolbar_botton.dart:398-402). Verification resolved OQ-10: mention.id is the CDP /users record id (a UUID string), which is distinct from sso_id — but MemberCdpResponse carries both id and sso_id (member_cdp_response.dart:22-30), and the CDP mapper currently picks id (member_cdp_mapper.dart:10). The backend keys mentions on SSO UUID.

Options considered.

  • Option A — change the mobile editor to emit a data-user-id anchor. Cons: the Quill→HTML codec does not support data-* attributes (PRD OQ-12) — larger codec change.
  • Option B — backend resolves {cdp_user_id} → sso_id at parse time (lookup via the user service). Cons: extra per-mention lookup; needs a {id}→sso_id map.
  • Option C — change only the CDP mapper (_mapMemberToMention, l.398-402) to emit sso_id in the href path (.../users/${mention.ssoId}/edit_user), and have the backend dual-parse both the web data-user-id form and the mobile href form (D-9), treating the href's captured id as already an SSO UUID.
    • Pros: one-line, isolated change (the CDP mapper is separate from the CRM mapper at l.246-251, so legacy CRM mentions are untouched); no Quill codec change; no backend id→sso lookup; sso_id is already in the response.

Decision. Option C — mobile CDP mapper emits sso_id; backend mention.Parser accepts both anchor forms (D-9) and normalizes to SSO UUID.

Rationale. Verification showed sso_id is already available at the picker, making Option C the minimal correct change and superseding the PRD's heavier OQ-10/OQ-12 framing. Backend dual-parse (D-9) is still required because the web form uses data-user-id while mobile uses the href form.

Consequences. A behavioral change to what {id} means in the CDP href only; backend must defensively validate that the captured value is a UUID (malformed → drop, Decision 5) since the href shape is loose.

Reversibility. Revert the mapper line; backend dual-parse remains harmless.


Decision 9: Mobile notification tap-through via origin = external_url (v1, mobile-qontak-crm only)

Context (grounded; re-verified 2026-06-22). This applies to mobile-qontak-crm only (the app that owns the CDP customer page — Decision 13). The One Notification V2 tap handler routes by origin and opens click_action_url when origin == external_url (verified notification_item_v2_mixin.dart:116-139; routing also in bottom_navigation_screen_mixin.dart:253-308); detailModuleRouteMapping keys are deal, contact, lead, task, company, expense, external_urlno CDP/Contact360/note key (verified qontak_app_route.dart:133-141). There are CRM fallback branches (crmPersonIdcontactDetail, etc.) but none targets a CDP contact note.

Delivery is automatic (verified). notification-service pushes FCM for all crm-origin notifications (fcmWhitelistedOrigins={"crm"}, service.go:47-49) and routes FCM tokens by user_source matching the origin (push_notification_service.go:69-94) — so a crm-origin mention reaches the mobile-qontak-crm device without any skip_fcm flag (which does not exist — Decision 10). The mobile half of the dispatch is therefore "free" once the /crm notification is sent; only tap-through routing is a client concern.

Options considered.

  • Option A — add a native CDP-contact route + origin. Cons: new mobile navigation surface; larger scope; needs a CDP contact-note screen route.
  • Option B — set origin = external_url and click_action_url = https://{host}/customers/{contact_id}?tab=notes. Pros: works today via the verified external_url path (opens web URL in browser/webview); zero new mobile route. Note: the web URL resolves to the unified-center → MFE path (Decision 13), so the same click_action_url serves both the web center click and the mobile tap.

Decision. Option B for v1 (D-10).

Rationale. external_url is the only verified working tap-through without new mobile routing. A native route is a deliberate later enhancement (OQ-5).

Consequences. Mobile mention notifications open the web note view, not a native screen. Must confirm the host/URL with Mobile Squad before beta. Avoids the "cannot redirect" toast (verified l.120-126).

Reversibility. Adding a native route later is additive (new mapping key + screen).


Decision 10: Reuse notification-service via a new outbound client — POST /api/v1/notifications/crm (contract VERIFIED 2026-06-22)

Context. No CDP-local notification mechanism should be built (PRD D-2). No notification client exists in contact-service today (verified — only Launchpad + Loyalty clients exist). Notifications are permanent — no retract (PRD D-6). notification-service is now in the workspace, so the contract is verified, not assumed.

The two endpoints (verified notification-service/.../route.go:57-58). The service exposes two ingestion endpoints: POST /api/v1/notifications/chat (Qontak Chat inbox — requires notif_type=3, notification_handler.go:225) and POST /api/v1/notifications/crm (CRM domain — requires notif_type ∈ {1 general, 2 approval}, notification_handler.go:313). A CDP note mention is a CRM-domain event using notif_category=mention(2) (a CRM category, notification_attributes.go:122-141), so it must use /crm. The earlier draft used /chat, which would be rejected.

Options considered.

  • Option A — build CDP-local email/notification. Cons: duplicates an existing platform service; rejected by PRD.
  • Option B — new NotificationClient in internal/app/api/ mirroring QontakLaunchpadClient (heimdall httpclient), calling POST /api/v1/notifications/crm with X-Api-Key. Pros: reuses the verified outbound-client pattern (qontak_launchpad.go:38, heimdall WithHTTPTimeout); consistent logging/tracing middleware.

Decision. Option B, targeting /crm.

Verified payload contract (notification_handler.go:67-83; auth flexible_auth_middleware.go:14-40):

  • Auth: X-Api-Key header (constant-time compared to the configured static key).
  • sso_id (string UUID, required — enforced in the handler at runtime, notification_handler.go:296-301, not via a struct validate tag) — the recipient. Identity is per-user; there is no company/organization-based recipient routing.
  • title (string, required) — e.g. "{Author} mentioned you in a note". (The earlier draft omitted this required field.)
  • description (string, required) — e.g. "On contact {Name}".
  • notif_type (string, required) = "1" (general) for a mention via /crm.
  • notif_category (string, optional) = "2" (mention).
  • click_action (enum OPEN_URL/OPEN_APP) + click_action_url (string) = https://{host}/customers/{contact_id}?tab=notes.
  • organization_id (*uuid.UUID, optional metadata) — populate with company_sso_id (itself a UUID) or omit. Not the recipient (corrects OQ-8).
  • event_type / event_id (optional), title_alt/description_alt (optional ID localization), is_reminder (optional), extra (generic map[string]interface{}, optional).
  • No skip_fcm field exists. FCM (mobile push) fan-out is automatic for crm-origin notifications (service.go:47-49) — see Decision 9.

Rationale. The platform service covers web (Notification Center) + mobile (FCM) delivery from one /crm call; the in-repo heimdall client pattern is the established way contact-service calls internal services.

Consequences. One remaining product question (OQ-1, narrowed): whether to reuse the existing generic mention(2) category or add a CDP-specific category in notification-service (a small platform-side change) so CDP mentions are distinguishable in analytics/filters. Reuse is sufficient for v1 and unblocks build; the decision is owned with Notification/Platform.

Reversibility. Remove the client; the job becomes a no-op behind the flag.


Decision 11: Typeahead source = existing company-user list, client-side filtered until a search param is confirmed

Context. The web app already fetches active company users: UserStore.getUsers()GET /v1/users?...&statuses=active on CUSTOMER_360_URL, paginated, returning {id, email, full_name, status, sso_id, ...} (verified UserStore.ts:83-129). The PRD's ?query= ILIKE search is now VERIFIED in Launchpad (GET /private/users?query=&statuses[]=active&sso_ids[]=&company_sso_id=, qontak-launchpad/user_handler.go:459, list_by_company.go:120-128) — OQ-6 resolved. Caveat: the list response does not include avatar (fall back to initials/full_name).

Options considered.

  • Option A — depend on a server-side ?query= search now. Cons: unverified; blocks the FE on an external change.
  • Option B — reuse getUsers() and filter client-side (debounced) for v1; escalate to a server search only if P95 > 500ms at company scale. Pros: uses a verified primitive; unblocks the FE; the response already carries sso_id for the web data-user-id anchor.

Decision. Option B; revisit with Launchpad if latency misses the 500ms target (OQ-6).

Rationale. Avoids coupling to an unverified endpoint; the existing list already returns everything the picker needs.

Consequences. Large companies may need pagination + client filtering tuning; the 500ms P95 target is the escalation trigger.

Reversibility. Swap the data source to a ?query= endpoint when available — the picker component contract is unchanged.


Decision 12: Web mention trigger in MpRichTextEditor — PARTIAL (preferred path pending OQ-7 spike; fallback chosen)

Context. NoteInput.vue consumes MpRichTextEditor from @mekari/pixel3 as a black box via :options/:value/@change/@action; editorOptions is a fixed toolbar array with no visible extension slot (verified NoteInput.vue:155-166). Whether pixel3 exposes a custom @-mention extension is UNVERIFIED — pixel3 is an external package, not in the workspace.

Options considered.

  • A — native pixel3 extension (preferred if it exists): smallest change, stays on the design-system editor.
  • B — escalate to the Pixel 3 team for an extension hook: unblocks A but couples timeline to another squad.
  • C — wrap a custom Tiptap mention editor for the notes composer only: fully under CDP control, no external dependency, but diverges from the DS editor for this one surface.

Decision (REV-3 fix — no longer dangling). Run the ≤3-day OQ-7 spike to confirm Option A. If A is not available, fall back to Option C (custom Tiptap mention editor scoped to the notes composer) — do not block on the Pixel 3 team (Option B is not the fallback). This pre-commits an executable path either way; the spike only decides which of A/C an agent builds.

Rationale. Tiptap is a known mention-capable editor and keeps the FE deliverable self-contained; a one-surface divergence from the DS editor is an acceptable cost to avoid a cross-squad dependency on the critical path.

Consequences. If the spike is skipped, an agent can proceed directly on Option C. The chip-render path (§4.D #6) and data-user-id contract are identical under A or C.

Reversibility. Migrating C → A later is a localized editor swap behind the same anchor contract; low cost.


Decision 13: Web rendering via the unified Notification Center + MFE embed; mobile delivery scoped to mobile-qontak-crm only (VERIFIED 2026-06-22)

Context (grounded). The web notification surface is one unified component, not a CDP-local UI. qontak-unified-component/packages/vue3/.../notification-center/NotificationCenter.vue is imported into the Qontak Chat FE hosts and mounted in their navbar (verified hub-chat/layouts/components/TheNavbar/TheNavbar.vue:79 import { NotificationCenter } from "qontak-unified-component/packages/vue3"). It fetches from {serviceUrl}/notif/v1/notifications filtered by origin/notif_type/notif_category (fetch-notifications.ts:19-34), already supports the mention category (typeAndCategory.ts:40-43), and its type model already carries CRM context (crm_note_id, crm_person_id in notifications.d.ts:18-49). On click, CRM-origin notifications navigate via click_action_url (hub-chat/layouts/composables/useNotificationCenter.ts:35-44).

The CDP app itself (qontak-customer-fe) is embedded into the hosts as a Micro-Frontend via module federation: it exposes ./RemoteContactRouter (qontak-customer-fe/nuxt.config.ts:92-111), and hub/hub-chat consume it for /customers/** (hub-chat/nuxt.config.ts:526 remoteContact, hub-chat/pages/customers/[...all].vue:69), rendering the customer DetailPage at /customers/{contact_id}.

For mobile, only mobile-qontak-crm owns a dedicated customer/CDP page with a notes screen + mention compose button (verified crm_note/.../note_screen.dart, mention_toolbar_botton.dart, route QontakAppRoute.note). mobile-qontak-chat has only room/conversation notes and basic contact info; its notification routing is chat/room-only (app.dart:85-113) with no origin-based CRM routing and no CDP notes feature.

Decision.

  1. Web: emit the mention notification with origin=crm, notif_category=mention(2), and click_action_url=https://{host}/customers/{contact_id}?tab=notes. No change to qontak-unified-component — it already renders the category. The CDP work is to ensure the deep-link lands on the notes tab inside the MFE.
  2. Mobile: deliver to mobile-qontak-crm only (automatic via crm-origin FCM, Decision 9). Do not target mobile-qontak-chat.

Rationale. Reuses the single existing web notification surface and the established MFE embed; avoids any new notification UI; and routes mobile to the only app that can actually display a CDP note.

Consequences / new gaps (→ OQ-17). Two host/MFE routing gaps must close for the web click to land correctly: (a) the customer page does not parse ?tab=notes to auto-select the Notes tab (CustomerActivityV2.vue), and (b) the Notes tab is currently gated behind isStaging===true. Both are small CDP-FE changes inside qontak-customer-fe; neither requires touching qontak-unified-component or the hosts. useNotificationCenter.redirectToCRMLink uses window.location.replace(click_action_url), which works for an absolute same-origin URL but treats it as a full navigation (acceptable for v1).

Reversibility. Additive — the notification simply would not deep-link cleanly if the gaps are left open; nothing else regresses.


Decision 14: Preview-card data source — FE-direct, lazy, client-cached Launchpad user-detail lookup (CHG-004 / S05)

Context (PRD v1.6 — CHG-004, S05). A rendered mention chip must, on hover (web) / tap (mobile), open a read-only preview card showing the mentioned user's name, email, staff level, and team(s) (Figma 15091-119388), with an inactive / not-found state (Figma 15091-448947). The note already stores the mentioned user's identity as mentioned_user_ids (SSO UUIDs — Decision 1); the card only needs to resolve that id to display fields. Two grounded facts constrain the design:

  • The verified single-user lookup IUserService.GetUserDetail(ctx, ssoId)LaunchpadUserDetailResponse (verified contact-service/internal/app/api/qontak_launchpad.go:223-224,545-556; GET /private/users/get_by_sso_id?sso_id=) returns full_name, email, sso_id, staff_level, statusbut carries no teams[] and no avatar. The FE already fetches the company team list separately (UserStore.getTeams()IAG_LAUNCHPAD_URL, verified UserStore.ts:62) and renders avatars from initials via MpAvatar (verified NotesList.vue:20).
  • Mobile renders mentions as plain text with no chip in v1 (Decision 7 — no HTML renderer), so there is no tappable target for a card.

Options considered (where the lookup lives).

  • Option A — FE-direct, lazy on hover, client-cached. The chip carries data-user-id; on hover the FE calls a new UserStore.getUserDetail(ssoId) (mirroring the existing getUsers/getTeams Pinia pattern), caches the result by sso_id for the session, and renders an MpPopover card (reusing the verified NotesList.vue:31-46 popover).
    • Pros: zero change to the note read path; only resolves the chips a user actually inspects (no fan-out); matches the existing FE data pattern; the card surface (MpPopover) already exists in this very component.
    • Cons: one lookup per distinct hovered user (mitigated by the session cache).
  • Option B — BE enriches the note-read response. GET …/notes resolves every mentioned_user_ids entry server-side and embeds a detail object.
    • Pros: one round trip.
    • Cons: changes the note read contract; pays the resolution cost for every mention on every list load even when no card is opened; adds N Launchpad calls per list page; couples a cosmetic read-only affordance to the core note endpoint. Over-engineered for a hover.

Decision. Option A — FE-direct, lazy, client-cached UserStore.getUserDetail(ssoId) feeding an MpPopover preview card on the web chip. The mobile card is deferred to the follow-up that introduces the mobile chip (Decision 7); v1 mobile shows plain @Name text with no card.

Rationale. The card is a read-only convenience over data the note already holds; resolving lazily on the client keeps the note write/read paths byte-for-byte unchanged (the zero-regression criterion) and only does work for chips the user inspects. The FE already owns the UserStore + MpPopover primitives, so this is additive with no new platform dependency and no BE change.

Teams + avatar gap (→ OQ-22). LaunchpadUserDetailResponse has no teams[] and no avatar. v1 resolves teams via the existing company getTeams() list if a user→team mapping is available client-side; otherwise the card renders name + email + staff level and omits the teams row (degraded, not broken — never blocks the card or the note). Avatar falls back to MpAvatar initials (same as the typeahead avatar-absent caveat, OQ-6). Whether to keep the degraded fallback or ask Launchpad to add teams[]/avatar to the get_by_sso_id response is OQ-22 — non-blocking for the card MVP.

Inactive / not-found state. Launchpad returns a generic 404 for any unresolved user — a deactivated/deleted user and a non-existent id are indistinguishable (same constraint as OQ-20). The card treats 404 as the inactive / not-found state (Figma 15091-448947): inactive-avatar indicator + last-known @Name taken from the chip text; it never errors the note view (S05/ERR-1). A 5xx/timeout shows a graceful "Couldn't load details" fallback with the note still readable (S05/ERR-2).

Consequences. New UserStore.getUserDetail(ssoId) method + a session cache keyed by sso_id; a new preview-card component bound to the chip via MpPopover; no BE change for the read path (the lookup is FE-direct to the same user source the typeahead uses). If a future "resolve once for the whole list" optimization is wanted, Option B can be added behind the same card contract.

Reversibility. Remove the MpPopover wrapper + the getUserDetail call; the chip reverts to static text. Low cost, no data change.


Detail 2.0 — Repo Reading Guide

Repo Map (mermaid, all layers)

flowchart LR
subgraph fe["qontak-customer-fe (Vue3/Nuxt4)"]
ni["Notes/components/NoteInput/NoteInput.vue"]
nl["Notes/components/NotesList/NotesList.vue"]
cstore["store/CustomerStore.ts (Pinia)"]
ustore["store/UserStore.ts (getUsers)"]
end
subgraph be["contact-service (Go/chi/Mongo)"]
rtr["server/rest_router.go"]
hdl["handler/contact_notes_handler.go"]
svc["service/contact_notes/contact_notes_service.go"]
repo["repository/contact_notes/*.go"]
lpc["api/qontak_launchpad.go (IUserService)"]
enq["service/job_enqueuer.go (IJobEnqueuer)"]
end
subgraph mob["mobile-qontak-crm (Flutter/melos)"]
dns["crm_note/.../detail_note_screen.dart"]
mtb["qontak_custom_form/.../mention_toolbar_botton.dart"]
nsd["crm_note/.../note_screen.dart"]
nim["crm_misc/.../notification_item_v2_mixin.dart"]
end
subgraph infra
mongo[("MongoDB contact_notes")]
redis[("Redis (gocraft/work)")]
ns(["notification-service (POST /api/v1/notifications/crm)"])
end
ni --> cstore --> hdl
ustore --> hdl
dns --> mtb
dns --> hdl
hdl --> svc --> repo --> mongo
svc --> lpc
svc --> enq --> redis --> ns
nl -.renders.-> mongo
nsd -.renders.-> mongo

Existing Code Anchors

LayerPathWhy the agent reads itWhat pattern it teaches
BEinternal/app/service/contact_notes/contact_notes_service.goThe note create/update logic to extendCreateNote(L42)/UpdateNote(L182); validateCreateNoteInput; 10k check L272
BEinternal/app/repository/contact_notes/base.goThe ContactNote Mongo doc to add a field tostruct L26-36; SetDefaults() L51-62; bson tags
BEinternal/app/repository/contact_notes/{create,read,update}.goHow notes are written/read in Mongomongo.Create; bson.M filters; soft-delete
BEinternal/server/rest_router.goWhere note routes + perms are registeredr2.Route("/{contact_id}/notes") L151-159; perm keys
BEinternal/app/handler/contact_notes_handler.goHandler shape; request decode + validateCreateNote L278; h.valid.Struct(req) L309; company/owner extraction
BEinternal/app/payload/contact_notes_request.goRequest/response structs to extendCreateContactNoteRequest{Note,Attachments}; ContactNoteResponse
BEinternal/app/api/qontak_launchpad.goOutbound client pattern to mirror for both validation + notificationQontakLaunchpadClient heimdall, NewQontakLaunchpadClient(rootURL,auth,timeout) L38
BEinternal/app/service/job_enqueuer.goThe async pattern to reuse for dispatchIJobEnqueuer.EnqueueJob L45-83; OTel propagation L59-71
BEinternal/app/handler/contact_sync_handler.goA live example of enqueuing a jobEnqueueJob(...CreateContactJobName...) L142,178
BEinternal/pkg/http/{handler,response,default_error}.goError envelope + success helperNewJSONResponse; BaseResponse{resp_code,resp_desc{id,en},meta}; ErrBadRequest/...
FEfeatures/customers/detail/components/Notes/components/NoteInput/NoteInput.vueComposer to add @ typeahead + chipMpRichTextEditor :options=editorOptions L155-166; save sanitize L460
FEfeatures/customers/detail/components/Notes/components/NotesList/NotesList.vueRender path to allow-list data-* + the MpPopover pattern to reuse for the preview card (Decision 14)sanitizeHtml L103-107 (no opts); v-html L49; MpAvatar L20; MpPopover/MpPopoverTrigger/MpPopoverContent for the note action menu L31-46 — repurpose for the hover card
FEfeatures/customers/store/CustomerStore.tsNote API callscreateNote POST /v1/contacts/notes/${id}; payload {note,attachments}
FEfeatures/customers/store/UserStore.tsTypeahead source + where getUserDetail(ssoId) is added (Decision 14)getUsers() GET /v1/users?statuses=active L83-129; User{sso_id,full_name}; getTeams() L62 (teams list — no per-user teams); no getUserDetail today → add it
BE (ref)internal/app/api/qontak_launchpad.goThe verified single-user detail response shape the FE card mirrors (Decision 14)GetUserDetail(ctx, ssoId) L223 → GET /private/users/get_by_sso_id L224; LaunchpadUserDetailResponse L545-556 has full_name/email/staff_level/status but no teams[]/avatar
FEfeatures/customers/properties/views/.../ModalFieldSetup.vue + UserSelectionStore.tsExisting MpAutocomplete user-picker precedentreusable typeahead-of-users pattern
Mobilefeatures/crm_note/.../detail_note/detail_note_screen.dartCDP editor + mention button + serializebutton L871-876; deltaToHtml L1392,1494; htmlToJson L346
Mobilefeatures/qontak_custom_form/.../toolbar/mention_toolbar_botton.dartCDP mapper to change (Decision 8)_mapMemberToMention L398-402 (href); picker L72-80
Mobilefeatures/qontak_custom_form/.../member_cdp/member_cdp_response.dartConfirms sso_id is availablefields id + sso_id L22-30
Mobilefeatures/crm_note/.../note/note_screen.dart + note_item.dartRender fallback (Decision 7)stripHtml() L307/336; plain Text L51
Mobilefeatures/crm_misc/.../notification_item_v2_mixin.dart + crm_core/.../qontak_app_route.dartNotification routing (Decision 9)external_url open L116-139; mapping L133-141

Existing Contracts to Reuse, Extend, or Replace (BE)

ContractStatusJustificationOwner
POST /iag/v1/contacts/{contact_id}/notesextendedSame endpoint; request HTML may carry mention anchors; response gains mentioned_user_ids + optional dropped_mentionsCDP Squad
PUT /iag/v1/contacts/{contact_id}/notes/{contact_note_id}extendedAdds mention parse + notified-set diff (re-mention/add allowed on edit — D-11); perm key customers_customernotes_manage unchangedCDP Squad
GET /iag/v1/contacts/{contact_id}/notesreusedReturns stored anchors as-is; FE renders chipCDP Squad
Company user list/search (typeahead)reused / extendedFE getUsers reused; server ?query= is new-with-justification only if added by Launchpad (OQ-6)Launchpad / CDP
Single-user detail GET /private/users/get_by_sso_id (preview card — S05)reusedVerified to exist (qontak_launchpad.go:223-224, LaunchpadUserDetailResponse:545-556); FE adds a thin UserStore.getUserDetail(ssoId) wrapper. Gap: response has no teams[]/avatar (OQ-22)Launchpad / CDP FE
launchpad.IUserService (validation)extendedAdd GetActiveUsersBySsoIds(ctx, companySsoId, ssoIDs) wrapping the existing GetUserListBySsoIds/GetUsersByUserSsoIds (returns SsoID+Status, company-scoped) and filtering Status=="active" (REV-5)CDP Squad
POST /api/v1/notifications/crm (notification-service)reusedPlatform-owned; contract verified 2026-06-22 (route.go:57-58, notification_handler.go); CDP uses /crm not /chatNotification/Platform
MentionNotifyJob (gocraft/work)new-with-justificationNo async exists in notes path; reuse the IJobEnqueuer pattern for durable retryCDP Squad
bluemonday sanitizer policynew-with-justificationNo server-side HTML sanitizer exists; required by D-5CDP Squad

Patterns to Follow

LayerConcernPattern in repoReference fileDeviation?
FEState managementPinia composition defineStorestore/UserStore.ts:179, store/CustomerStore.tsnone
FEError / toasttoastNotify wrapping pixel3 toast.notify; getApiErrorMessage reads resp_desc.enutils/toast.ts, CustomerStore.ts:228-231none
FESanitizationDOMPurify.sanitize(value) option-less (save + render)NoteInput.vue:460, NotesList.vue:104yes — add {ADD_ATTR:['data-user-id','data-mention']} at BOTH sites
FEHTTPNuxt $customFetch ($fetch.create) — not axioscommon/composables/useCustomFetch.tsnone
FEHover card / popover (preview card — Decision 14)MpPopover/MpPopoverTrigger/MpPopoverContent already used for the note action menuNotesList.vue:31-46none (repurpose the same component for the mention preview card)
FEAvatarMpAvatar initials (Launchpad list/detail has no avatar)NotesList.vue:20none (card avatar falls back to initials — OQ-22)
BEHTTP handler shapefunc(w,r)(myhttp.ResponseBody,error) wrapped by myHandler; decode→valid.Struct→service→NewJSONResponsehandler/contact_notes_handler.go:278, pkg/http/handler.go:66-85none
BERepository / DBMongo via repository.IDbRepo; bson.M filters; SetDefaults()repository/contact_notes/*.go, repository/db.go:14-28none
BEAsync producerIJobEnqueuer.EnqueueJob(ctx,jobName,params) (gocraft/work)service/job_enqueuer.go:45-83none (net-new consumer for notes)
BEOutbound HTTPheimdall httpclient with timeout + log middlewareapi/qontak_launchpad.go:38-48none (new client, same shape)
BEError responseBaseResponse{resp_code,resp_desc{id,en},meta}; ErrBadRequest()/ErrUnprocessableEntity()/...pkg/http/{response,default_error}.gonone
BELogging / tracingslog.*Context(ctx,...); OTel + Datadogthroughout notes svc/reponone
MobileMention insertQuill LinkAttribute(href) via _mapMemberToMentionmention_toolbar_botton.dart:398-402yes — emit ssoId not id (Decision 8)
MobileNote renderstripHtml() + plain Textnote_screen.dart:307/336, note_item.dart:51none (fallback kept, Decision 7)
CrossNaming (snake_case API ↔ camelCase FE / Dart)API returns snake_case (sso_id,full_name); FE/Dart map to camelCaseUserStore.ts:11-19, member_cdp_response.dartnone

Reading Order for the Agent

  1. contact-service: internal/app/service/contact_notes/contact_notes_service.go — the write path to extend (create/update, 10k check).
  2. contact-service: internal/app/repository/contact_notes/base.go — the doc to add mentioned_user_ids to.
  3. contact-service: internal/app/handler/contact_notes_handler.go + internal/server/rest_router.go — handler shape, perms, company/owner extraction.
  4. contact-service: internal/app/api/qontak_launchpad.go — outbound client pattern (clone for the notification client).
  5. contact-service: internal/app/service/job_enqueuer.go + internal/app/handler/contact_sync_handler.go — the async enqueue pattern to reuse.
  6. qontak-customer-fe: features/customers/detail/components/Notes/components/NoteInput/NoteInput.vue — composer + save-path sanitize.
  7. qontak-customer-fe: .../NotesList/NotesList.vue + store/UserStore.ts — render sanitize + typeahead source; also the MpPopover block (NotesList.vue:31-46) to reuse for the preview card and where getUserDetail(ssoId) is added (Decision 14 / S05).
  8. mobile-qontak-crm: features/qontak_custom_form/.../mention_toolbar_botton.dart + .../member_cdp/member_cdp_response.dart — the mapper to change + proof sso_id exists.
  9. mobile-qontak-crm: features/crm_misc/.../notification_item_v2_mixin.dart + crm_core/.../qontak_app_route.dart — notification routing.
  10. documents: cdp/notes-mention-user/prds/prd-notes-mention-user.md — decisions D-1…D-10, OQ-1…OQ-12.

Source Verification (anti-hallucination — required)

LayerAnchor / claimVerified byEvidence (1-line)
BE10,000-char limitreadcontact_notes_service.go:272 if len(req.Note) > 10000 { return errors.New("note content exceeds maximum length of 10,000 characters") } (PRD said 268-274; actual 272-274)
BEContactNote doc + no mention fieldsreadbase.go:26-36 fields Note,CompanySsoID,OwnerID,Attachments,IsDeleted,Created/UpdatedAt; SetDefaults() L51-62; grep mention → only UI icon-key "text-editor-mention", no feature
BEDatastore = MongoDB (not SQL)read/grepgo.mod go.mongodb.org/mongo-driver v1.12.1; migrations db/migrations/013_create_contact_notes.up.json (createIndexes)
BERoutes + permsreadrest_router.go:151-159 POST CustomersCustomerNotesAddKey, PUT CustomersCustomerNotesManageKey, GET ...ViewKey
BEcompany/owner identity sourcereadcontact_notes_handler.go:470 owner from header X-Authenticated-Userid; company from ctx set in require_permission_middleware.go:97 (Launchpad CRS)
BENo async in notes pathgrep`go func
BEAsync pattern existsreadjob_enqueuer.go:45-83 EnqueueJob; OTel inject L59-71; used contact_sync_handler.go:142,178
BEOutbound client patternreadqontak_launchpad.go:38 NewQontakLaunchpadClient(rootURL,auth,timeout) heimdall WithHTTPTimeout
BEError envelopereadpkg/http/response.go:12-27 BaseResponse{ResponseCode,ResponseDesc{id,en},Meta}
BETest/build commandsreadMakefile test(L79-84), lintstaticcheck(L137-140), secgosec(L142-145), build(L45-48), migrate-up(L173)
FEVue3/Nuxt4 + pixel3readpackage.json:42 vue ^3.5.13, :37 nuxt ^4.2.2, :24 @mekari/pixel3 1.0.10-dev.0
FEeditorOptions fixed arrayreadNoteInput.vue:155-166 RichTextEditorOption[][]; no mention/extension slot
FE"Private" is static textreadNoteInput.vue:39-44 literal Private text + info icon, no selector
FEsave-path sanitize (option-less)readNoteInput.vue:460 note: DOMPurify.sanitize(noteValue.value)
FErender sanitize (option-less) + v-htmlreadNotesList.vue:103-107 DOMPurify.sanitize(value); :49 v-html="sanitizeHtml(note.note)"; MpAvatar L20
FEnote API + payloadreadCustomerStore.ts createNote POST /v1/contacts/notes/${contactId}; payload {note:string,attachments}
FEtypeahead source existsreadUserStore.ts:83-129 getUsers() GET /v1/users?...statuses=active; User{id,email,full_name,status,sso_id}
FEpreview-card popover pattern existsreadNotesList.vue:31-46 MpPopover/MpPopoverTrigger/MpPopoverContent/MpPopoverList used for the note action menu → reuse for the hover card; no dedicated user-card component today
FEno per-user detail fetch todayreadUserStore.ts has getUsers()(L83), getTeams()(L62), getPermission()(L132) — no getUserDetail(ssoId); teams are a separate list, not on the user object
BE (ref)single-user detail response shape (card fields)readqontak_launchpad.go:223-224 GetUserDetailGET /private/users/get_by_sso_id?sso_id=; LaunchpadUserDetailResponse:545-556 = full_name,email,sso_id,staff_level,status,role_crs_id,phone,nik,...no teams[], no avatar (OQ-22); cached in service/launchpad/user_service.go:79
Mobilepreview card has no v1 targetreadmentions render plain text (note_screen.dart:307/336 stripHtml(), note_item.dart:51) — no chip to tap → mobile card deferred (Decision 7/14)
FEno mention infragrep\bmention across .vue/.ts → 0 functional hits
FEtest/buildreadpackage.json testvitest(L16), linteslint .(L18), buildnuxt build(L6); no e2e/typecheck script
Mobilemention button (CDP)readdetail_note_screen.dart:871-876 MpMentionToolbarButtonX(...,isCdp: widget.argument.isCdp) (PRD said 728-733)
Mobilehref insert + {id} type (OQ-10)readmention_toolbar_botton.dart:398-402 '../../../users/${mention.id}/edit_user'; member_cdp_response.dart:22-30 has both id and sso_id; mapper uses id (member_cdp_mapper.dart:10)
Mobileserialize/deserializereaddeltaToHtml L1392,1494; htmlToJson L346 (PRD said 1252/1348/329)
Mobileno HTML renderer; stripHtmlread/grepno flutter_html/markdown dep; note_screen.dart:307/336 .stripHtml(); note_item.dart:51 plain Text (PRD said 264)
Mobilenotif enumsreadnotif_category.dart:30,56 mention='2'; notif_type_enum.dart:2-3 general(1),approval(2)
Mobileroutingreadnotification_item_v2_mixin.dart:116-139 opens click_action_url only origin==external_url, else toast L120-126; qontak_app_route.dart:133-141 keys lack CDP
Mobilegating flagsreadfeature_flag_constant.dart:78-82 flag_one_notification default false; profile.dart:58 useQontakOneNotif=false; combined bottom_navigation_screen_mixin.dart:64-77
Mobiletest/buildreadmelos.yaml testflutter test test/main_test.dart L97-99; analyzeflutter analyze L77-79; FVM Flutter 3.27.4 (.fvmrc)
NotifSvcendpoints + auth + payloadreadnotification-service: route.go:57-58 (/chat + /crm); notification_handler.go:67-83 (request struct; sso_id/title/description required, organization_id *uuid.UUID optional, no skip_fcm), :225 (/chat needs type 3), :313 (/crm needs type 1/2); flexible_auth_middleware.go:14-40 (X-Api-Key)
NotifSvctaxonomy + FCM fanoutreadnotification_attributes.go:122-141 mention="2" (CRM category); type general="1"; service.go:47-49 fcmWhitelistedOrigins={"crm"}; push_notification_service.go:69-94 FCM token routed by user_source
Webunified Notification Center + clickreadqontak-unified-component: notification-center/NotificationCenter.vue; typeAndCategory.ts:40-43 (mention supported); fetch-notifications.ts:19-34 (/notif/v1/notifications); notifications.d.ts:18-49 (crm_note_id/crm_person_id); imported hub-chat/layouts/components/TheNavbar/TheNavbar.vue:79; click routing hub-chat/layouts/composables/useNotificationCenter.ts:35-44
WebMFE embedreadqontak-customer-fe/nuxt.config.ts:92-111 exposes RemoteContactRouter; hub-chat/nuxt.config.ts:526 remoteContact; hub-chat/pages/customers/[...all].vue:69; customer detail /customers/{id}; Notes tab gated isStaging in CustomerActivityV2.vue
Launchpaduser search + company UUIDreadqontak-launchpad: GET /private/users?query=&statuses[]=active&sso_ids[]=&company_sso_id= (user_handler.go:459, list_by_company.go:120-128); company id/sso_id both UUID + external_company_id int (models.go:15-27); avatar absent from list response
Mobileapp scopingreadmobile-qontak-crm owns CDP notes (crm_note/.../note_screen.dart, mention_toolbar_botton.dart, QontakAppRoute.note, routing bottom_navigation_screen_mixin.dart:253-308); mobile-qontak-chat has no CDP notes + chat/room-only routing (app.dart:85-113, contact_detail_screen.dart)
ExtCRM note.rb parity referenceUNVERIFIED — qontak.com repo not in workspace (conceptual precedent only)

Design ↔ Code Mapping (frontend half)

Figma frame / componentImplementing fileReuse vs newTokensBacking APIDeviation
Mention typeahead pickerNoteInput.vue (+ new MentionPicker or MpAutocomplete)new (or reuse MpAutocomplete precedent)TBD (design pending)getUsers() (Decision 11)n/a — design pending (OQ-7 gate)
Mention chip (rendered)NotesList.vue chip CSS + DOMPurify ADD_ATTRextendedTBDGET …/notesn/a — design pending
Mention preview card (web, S05) — Figma 15091-119388NotesList.vue chip + new preview-card component (reuse MpPopover L31-46)new (reuse MpPopover)pixel3getUserDetail(ssoId) → Launchpad get_by_sso_id (Decision 14)teams omitted if unresolved (OQ-22); avatar = initials
Preview card inactive/not-found (S05/ERR-1) — Figma 15091-448947same component, inactive statenewpixel3generic 404 (OQ-20)never errors the note view
Mobile mention displaynote_screen.dart / note_item.dartreused (plain text)n/aGET …/notesDecision 7 — plain-text fallback
Mobile preview cardn/a — deferred v1deferredn/an/ano tappable chip (Decision 7/14)

Detail 2.1 — Architecture (mermaid)

End-to-end component diagram

flowchart TB
agent([Agent]) --> ni["NoteInput.vue (web) / detail_note_screen.dart (mobile)"]
ni -->|"@ search"| usrc["getUsers / GetListMemberCdp"]
ni -->|"POST/PUT note (HTML w/ anchors)"| hdl["contact_notes_handler"]
hdl --> svc["ContactNotesService"]
svc --> parser["mention.Parser (D-9)"]
svc -->|validate sso_ids| ius["launchpad.IUserService"]
svc -->|sanitize| san["bluemonday notePolicy"]
svc --> repo["contact_notes repo"] --> mongo[("MongoDB")]
svc -->|"per not-yet-notified mention"| enq["IJobEnqueuer.EnqueueJob"]
enq --> redis[("Redis")] --> worker["MentionNotifyJob (worker)"]
worker -->|"POST /api/v1/notifications/crm"| ns(["notification-service"])
ns --> nc(["Notification Center (qontak-unified in hub/hub-chat) / One Notif V2 (mobile-qontak-crm)"])
nl["NotesList.vue / note_screen.dart"] -.GET notes.-> mongo

Data model (mermaid erDiagram) — MongoDB document

erDiagram
CONTACT_NOTES {
objectid _id PK
string contact_id
string company_sso_id
string note "HTML, max 10000 chars, server-sanitized (D-5)"
string_array mentioned_user_ids "NEW — current mention SSO UUIDs (D-1)"
string_array notified_user_ids "NEW — append-only lifetime notified set (D-4/D-11/OQ-14)"
attachment_array attachments
string owner_id
bool is_deleted "soft-delete (existing)"
timestamp created_at
timestamp updated_at
}

contact_notes is a MongoDB collection; the diagram shows the document shape, not relational tables. The new fields are mentioned_user_ids (current mentions, for rendering) and notified_user_ids (append-only lifetime notified set, for idempotent dispatch — Decision 4).

State machine for every status enum

stateDiagram-v2
[*] --> active : note created
active --> active : update (re-parse mentions, notify not-yet-notified)
active --> soft_deleted : delete (is_deleted=true)
soft_deleted --> [*]
note right of active
No status enum on ContactNote.
Only is_deleted soft-delete (verified base.go).
Shown for completeness.
end note

Branch & skip flow (per non-error policy branch)

flowchart TD
save([note save]) --> flag{cdp_notes_mention_enabled?}
flag -- OFF --> today[store note as today; no parse/validate/dispatch]
flag -- ON --> parse[parse anchors -> sso_ids]
parse --> self{recipient == author?}
self -- yes --> dropself[exclude from notify - S03/ERR-2]
self -- no --> valid{valid active company user?}
valid -- no --> drop[drop id + warn - Decision 5; log cdp_note_mention_invalid]
valid -- yes --> diff{"already in notified_user_ids? (Decision 4)"}
diff -- "yes (already notified / re-added)" --> skipn[no notify - Decision 4]
diff -- "no (not yet notified)" --> notify["enqueue MentionNotifyJob; append to notified_user_ids"]
today --> done([done])
dropself --> done
drop --> done
skipn --> done
notify --> done

Detail 2.2 — Sequence (mermaid, end-to-end incl. failure paths)

Happy path — create note with a new mention (web)

sequenceDiagram
actor Agent
participant FE as NoteInput.vue
participant LB as Gateway
participant API as contact-service-api
participant SVC as ContactNotesService
participant IUS as IUserService (Launchpad)
participant DBW as MongoDB
participant Q as Redis (gocraft/work)
participant W as MentionNotifyJob worker
participant NS as Notification Service
Agent->>FE: type "@bu", pick user
FE->>API: GET company users (typeahead, debounced)
API-->>FE: [{sso_id, full_name, avatar}]
Agent->>FE: Save (HTML w/ data-user-id anchor; DOMPurify ADD_ATTR keeps it)
FE->>LB: POST /iag/v1/contacts/{id}/notes
LB->>API: HTTP
API->>SVC: CreateNote(req)
SVC->>SVC: validate len<=10000, <=10 mentions; parse anchors (D-9)
SVC->>IUS: validate sso_ids in company (active)
IUS-->>SVC: {valid:[...], invalid:[...]}
SVC->>SVC: sanitize HTML (bluemonday); drop invalid (Decision 5); filter self
SVC->>SVC: to_notify = parsed_valid − notified_user_ids − self (Decision 4)
SVC->>DBW: insert note + mentioned_user_ids + notified_user_ids (∪ to_notify)
DBW-->>SVC: ok
SVC->>Q: EnqueueJob(MentionNotifyJob, per id in to_notify)
Note over SVC,Q: edit path is identical — notified_user_ids is append-only, so re-mention or remove-then-re-add enqueues nothing (OQ-14)
SVC-->>API: 200 {note, mentioned_user_ids, dropped_mentions?}
API-->>FE: 200
Note over Q,W: async (does not block write)
W->>NS: POST /api/v1/notifications/crm (X-Api-Key, sso_id, title, description, click_action_url, notif_type=1, notif_category=2)
Note right of NS: fans out to web center + mobile FCM (crm origin, auto); timeout 10s
NS-->>W: 200
W->>W: log cdp_note_mention_notify_sent

Failure path — Notification Service 5xx/timeout (async, note unaffected)

sequenceDiagram
participant W as MentionNotifyJob worker
participant NS as Notification Service
W->>NS: POST /api/v1/notifications/crm
Note right of NS: timeout after 10s
NS--xW: 5xx / no response
W->>W: retry x3 (exponential backoff)
alt still failing
W->>W: log cdp_note_mention_notify_failed {reason, retry_count}
Note over W: note remains saved (best-effort, non-blocking — S03/ERR-1)
else succeeds on retry
W->>W: log cdp_note_mention_notify_sent
end

Failure path — invalid / cross-company mention on save (drop-and-warn)

sequenceDiagram
participant SVC as ContactNotesService
participant IUS as IUserService
participant DBW as MongoDB
SVC->>IUS: validate sso_ids (company-scoped)
IUS-->>SVC: id X not active in company
SVC->>SVC: drop X; sanitize its anchor to text; log cdp_note_mention_invalid {action:dropped}
SVC->>DBW: persist note with remaining valid ids
SVC-->>SVC: response.dropped_mentions = [X] (FE shows warn chip)

Failure path — typeahead source unavailable (web)

sequenceDiagram
actor Agent
participant FE as NoteInput.vue
participant API as user source
Agent->>FE: type "@"
FE->>API: GET company users
API--xFE: 5xx / timeout
FE-->>Agent: "Couldn't load people — try again" (note still savable w/o mention)
FE->>FE: log cdp_note_mention_picker_failed {company_sso_id, platform}

Preview card — resolve a mention on hover (web, read-only — CHG-004 / S05, Decision 14)

sequenceDiagram
actor Agent
participant FE as NotesList.vue (chip + MpPopover)
participant US as UserStore (session cache)
participant LP as Launchpad (get_by_sso_id)
Agent->>FE: hover mention chip (data-user-id = sso_id)
FE->>US: getUserDetail(sso_id)
alt cached
US-->>FE: cached {full_name, email, staff_level, status}
else not cached
US->>LP: GET /private/users/get_by_sso_id?sso_id=
alt active user (200)
LP-->>US: {full_name, email, staff_level, status} (no teams, no avatar)
US-->>FE: detail (cache by sso_id)
FE->>FE: render card (teams omitted if unresolved — OQ-22, avatar = initials)
else inactive or not found (404)
LP--xUS: 404 (deleted or non-existent — indistinguishable, OQ-20)
US-->>FE: not-found
FE->>FE: render inactive / not-found card (Figma 15091-448947 — S05/ERR-1)
else lookup error (5xx or timeout)
LP--xUS: 5xx / timeout
US-->>FE: error
FE->>FE: graceful fallback "Couldn't load details", note readable (S05/ERR-2)
end
end

Read-only: no write path, no notification, no change to GET …/notes. The card resolves the chip's already-stored sso_id lazily and caches by id for the session (Decision 14).

Detail 2.3 — Database Model (MongoDB document)

No SQL DDL (verified MongoDB). The change is one new field on the contact_notes document and an optional index migration.

Go struct delta (internal/app/repository/contact_notes/base.go):

type ContactNote struct {
ID *primitive.ObjectID `bson:"_id,omitempty"`
ContactID string `bson:"contact_id" validate:"required"`
CompanySsoID string `bson:"company_sso_id" validate:"required"`
Note string `bson:"note" validate:"required,max=10000"`
MentionedUserIDs []string `bson:"mentioned_user_ids,omitempty"` // NEW (D-1) — current mention SSO UUIDs
NotifiedUserIDs []string `bson:"notified_user_ids,omitempty"` // NEW (D-4/D-11/OQ-14) — append-only lifetime notified set (dedup; never pruned)
Attachments []Attachment `bson:"attachments,omitempty"`
OwnerID string `bson:"owner_id" validate:"required"`
IsDeleted *bool `bson:"is_deleted,omitempty"`
CreatedAt time.Time `bson:"created_at"`
UpdatedAt time.Time `bson:"updated_at"`
}

Optional index migration (only if a future "mentions me" query is added — not required for v1 fan-out): db/migrations/0NN_contact_notes_mention_index.up.json

[
{ "createIndexes": "contact_notes",
"indexes": [
{ "key": {"mentioned_user_ids": 1, "company_sso_id": 1}, "name": "idx_contact_notes_mentioned_company" }
] }
]
  • Cardinality / growth: one array per note, ≤10 entries (Decision 6); negligible storage delta.
  • Example doc: { "note": "<p>cc <a data-user-id=\"3f...\" data-mention=\"true\">@Budi</a></p>", "mentioned_user_ids": ["3f2504e0-4f89-41d3-9a0c-0305e82c3301"], "notified_user_ids": ["3f2504e0-4f89-41d3-9a0c-0305e82c3301"], "company_sso_id": "comp-abc", "owner_id": "usr-1" }
  • PII classification: mentioned_user_ids + notified_user_ids = pseudonymous user identifiers (PII-linked); note = user-generated content (may contain PII). Same retention as the note doc (unchanged); notified_user_ids deletes with the note (soft-delete unchanged).
  • Per-status lifecycle: n/a — ContactNote has no status enum (only is_deleted soft-delete, unchanged).
  • Partition/sharding: none (existing Mongo collection unchanged).
  • NoSQL alternative considered: n/a — already MongoDB; Decision 1 rejected a second collection.

Detail 2.4 — APIs

Outbound endpoints (consumers call us)

EndpointMethodAuthN/AuthZRequest schemaResponse schemaStatus codesIdempotencyVersioningReuse?
/iag/v1/contacts/{contact_id}/notesPOSTchi RequirePermissionMiddleware + customers_customernotes_add; owner from X-Authenticated-Userid{ note: string(HTML, ≤10000), attachments?: [] } (unchanged shape; HTML may carry mention anchors){ contact_note_id, created_at, updated_at, mentioned_user_ids: string[], dropped_mentions?: string[] }200, 400 (validation), 401, 403, 422 (TOO_MANY_MENTIONS/NOTE_TOO_LONG), 500none (create)path /iag/v1extended
/iag/v1/contacts/{contact_id}/notes/{contact_note_id}PUT+ customers_customernotes_manage; owner-only (verified service:196)same as POSTContactNoteResponse + mentioned_user_ids + dropped_mentions?200, 400, 401, 403, 404, 422, 500last-write-wins (no version field today)/iag/v1extended
/iag/v1/contacts/{contact_id}/notesGET+ customers_customernotes_viewquery: paginationlist incl. stored note HTML + mentioned_user_ids200, 401, 403, 500n/a/iag/v1reused
Company user list (typeahead)GETsession auth?statuses=active&page&per_page (+ query= if/when Launchpad adds it — OQ-6)[{ id, sso_id, full_name, avatar?, status }]200, 5xxn/a/v1 (verified FE call)reused / extended
Single-user detail (preview card — S05; FE-direct, lazy on hover)GETsession authget_by_sso_id?sso_id={uuid}{ full_name, email, sso_id, staff_level, status }no teams[], no avatar (OQ-22)200, 404 (inactive/not-found — generic, OQ-20), 5xxn/aLaunchpad get_by_sso_id (verified qontak_launchpad.go:223-224,545-556)reused

Note path reality (verified): the backend serves the canonical /iag/v1/contacts/{contact_id}/notes and a deprecated /iag/v1/contacts/notes/{contact_id} group (rest_router.go:162-169). The web FE currently calls the deprecated shape (/v1/contacts/notes/{contactId} on CUSTOMER_360_URL); mobile calls the canonical shape (/contacts/{contactId}/notes). The mention change rides on whichever shape each client already uses — no new note endpoint is added.

Inbound webhooks (other services call us)

N/A — no inbound webhook. The Notification Service call is outbound only; mark-as-read and notification rendering are owned by the Notification Center / mobile app, not contact-service.

Outbound dependency call — notification-service (we call them)

CallMethodAuthRequest (per recipient)TimeoutRetryFailure behavior
POST /api/v1/notifications/crmPOSTX-Api-Key (service){ sso_id:<recipient UUID, required>, title:"{Author} mentioned you in a note" (required), description:"On contact {Name}" (required), notif_type:"1" (general — required for /crm), notif_category:"2" (mention), click_action:"OPEN_URL", click_action_url:"https://{host}/customers/{contact_id}?tab=notes", organization_id:<company_sso_id UUID — optional>, event_type?, event_id? }10s3× exp backoff (in MentionNotifyJob)log cdp_note_mention_notify_failed; note stays saved

Contract status: VERIFIED 2026-06-22 against notification-service (notification_handler.go:67-83, route.go:57-58, auth flexible_auth_middleware.go:14-40). Corrections vs the prior draft: endpoint is /crm (the /chat endpoint requires notif_type=3); title is required and was missing; there is no skip_fcm field — FCM (mobile) fan-out is automatic for crm-origin (service.go:47-49), so the same single call delivers to both the web center and mobile-qontak-crm; organization_id is optional *uuid.UUID metadata, not the recipient (OQ-8 resolved); there is no top-level extra.origin field on the request — extra is a generic map and origin is determined by which endpoint (/crm) is called. Mobile tap-through still uses the external_url routing path (Decision 9) on the consuming side. The only open product choice (OQ-1, narrowed): reuse the generic mention(2) category or add a CDP-specific one (platform-side).

Detail 2.A — UI Contract

Web — mention typeahead + chip (NoteInput.vue / NotesList.vue)

  • Figma frame URL: n/a — design pending (OQ-7 gate).
  • Implementation: features/customers/detail/components/Notes/components/NoteInput/NoteInput.vue (composer), .../NotesList/NotesList.vue (chip).
  • Mention anchor emitted by the composer (web): <a data-user-id="{sso_uuid}" data-mention="true">@{full_name}</a>.
  • DOMPurify config (BOTH sites): DOMPurify.sanitize(value, { ADD_ATTR: ['data-user-id', 'data-mention'] }) at NoteInput.vue:460 (save) and NotesList.vue:104 (render). Without this, mention identity is stripped silently (verified default behavior).
  • State ownership: Pinia CustomerStore (note CRUD) + UserStore (typeahead source); local noteValue ref in NoteInput.vue.
  • Analytics events: cdp_note_mention_added (create or edit with ≥1 mention; carries mention_count total + new_mention_count + event), cdp_note_mention_picker_failed (typeahead error).
  • A11y: typeahead listbox role="listbox"/option, arrow-key navigation, aria-activedescendant; mention chip role="link" with accessible name @{full_name}.

Mobile — compose (detail_note_screen.dart / mention_toolbar_botton.dart)

  • Reuses the shipped MpMentionToolbarButtonX(isCdp:true); the only change is the CDP mapper emitting sso_id (Decision 8).
  • Anchor emitted: <a href="../../../users/{sso_uuid}/edit_user">@{full_name}</a>.
  • Render: plain-text @Name fallback (Decision 7).

Web — mention preview card (NotesList.vue — CHG-004 / S05, Decision 14)

  • Figma: happy 15091-119388 · inactive/not-found 15091-448947.
  • Implementation: wrap the rendered chip in MpPopover/MpPopoverTrigger/MpPopoverContent (reuse the NotesList.vue:31-46 pattern) + a new preview-card component.
  • Trigger: hover (desktop) on the chip; the chip's data-user-id supplies the sso_id.
  • Data: UserStore.getUserDetail(ssoId){ full_name, email, staff_level, status } (Launchpad get_by_sso_id); teams resolved via getTeams() mapping or omitted (OQ-22); avatar = MpAvatar initials. Client-cached by sso_id for the session.
  • States: loading skeleton → success (name/email/staff level/team(s)) → inactive/not-found (generic 404, S05/ERR-1) → error ("Couldn't load details", S05/ERR-2). Dismiss on cursor-away; note stays readable (S05/AC-3).
  • Read-only: no write path, no notification; reads only company-scoped directory data (any customers_customernotes_view user).
  • Analytics (optional, FE-local): cdp_note_mention_preview_failed on lookup error (mirrors cdp_note_mention_picker_failed).
  • A11y: card is keyboard-focusable from the chip (role="link"); card content readable by SR; Esc closes.

Mobile — mention preview card: n/a — deferred v1 (no tappable chip; Decision 7 / 14). Ships with the deferred mobile chip.

Detail 2.B — Data-Fetching Strategy (FE typeahead)

  • Library: Nuxt $customFetch ($fetch.create), via Pinia UserStore.getUsers().
  • Cache key: in-store users ref (paginated accumulation) keyed by company session.
  • TTL / refetch: fetched on composer open; client-side filter on keystroke (debounced ~250ms).
  • SWR: no — simple in-memory list for the session.
  • Optimistic updates: n/a (read-only list).
  • Escalation: if P95 > 500ms at company scale, move filtering to a server ?query= endpoint (OQ-6).
  • Preview card (S05, Decision 14): separate from the typeahead — UserStore.getUserDetail(ssoId) → Launchpad get_by_sso_id, fired lazily on hover (not on list load), result cached by sso_id for the session so repeat hovers don't refetch; 404 → inactive/not-found state, 5xx → graceful fallback. Read-only; never touches the note read/write path.

Detail 2.C — UI State Matrix

SurfaceLoadingEmptyErrorPartialSuccess
Web typeaheadspinner in dropdown"No matching people.""Couldn't load people — try again" (note still savable)n/ausers listed w/ avatar
Web mention chip (render)n/an/a (no mentions → plain note)dropped mention → warn chip (Decision 5)mix of chips + textstyled @Name chip
Mobile pickerlist skeleton"No matching people.""People list unavailable — check your connection"n/amember list
Mobile note rendern/an/anever raw HTML (stripHtml)n/aplain @Name text
Web preview card (S05)skeleton/spinner while resolvingn/a (card only opens on a chip)inactive/not-found (404, Figma 15091-448947) or "Couldn't load details" (5xx) — note stays readableteams row omitted if unresolved (OQ-22)card: name, email, staff level, team(s)
Mobile preview cardn/a — deferred v1n/an/an/an/a (Decision 7/14)

Detail 2.D — Data Integrity Matrix

Write pathTransaction scopePartial failureIdempotencyConsistencyDuplicate-event handlingStale-read
Create note + mentionssingle Mongo doc insert (atomic)if insert fails → 5xx, nothing persisted, no enqueuenone (create); dedupe mentions within the note (Set)strong (single doc)n/a (create)n/a
Update note + mentionssingle Mongo doc $set (atomic; sets mentioned_user_ids + appends to notified_user_ids)if update fails → 5xx, no enqueue, set not appendedlast-write-winsstrong (single doc)append-only notified_user_ids prevents re-notify incl. remove→re-add (Decision 4 / OQ-14)reads the doc it just loaded (GetNoteByID)
Enqueue notify jobbest-effort after commitenqueue fails → log, note savedper-recipient job; mandatory idempotency key = note_id + recipient_sso_id (REV-7 fix — excludes note_updated_at so concurrent PUTs dedup; a recipient is notified at most once per note lifetime)eventualdedupe key prevents double-send on job retry and across concurrent PUTsn/a

Detail 2.E — Concurrency Collision Map

ResourceWritersCollisionResolutionOn conflict
Same note doctwo agents (web + mobile) editing concurrentlyboth PUT → last-write-wins (no version field today)existing behavior unchanged (no optimistic lock today)each PUT recomputes to_notify against the notified_user_ids it read — if both read before either appends, both enqueue the same new mention. Mitigated by the timestamp-free idempotency key (note_id + recipient_sso_id, REV-7) which dedups even across concurrent PUTs (the persisted append-only set is the primary guarantee; the key is the race-safety net). Last-write-wins on the note body itself remains the inherited limitation (no version field today).
Notify jobworker retriessame job retriedidempotency key dedupe (note_id + recipient_sso_id)at-least-once delivery; key avoids duplicate notification on retry and across concurrent PUTs

Detail 2.F — Async Job / Event Consumer Spec

JobTriggerInput shapeRetryDLQConcurrencyIdempotency keyTimeoutPoison handling
MentionNotifyJobenqueued per not-yet-notified mention (parsed − notified_user_ids − self) after note commit, on create and edit (Decision 4){ note_id, contact_id, contact_name, company_sso_id, author_sso_id, recipient_sso_id, click_action_url }MaxFails: 3 per job + ExponentialBackoff (verified worker_service.go:60-72; backoff sequence 1,4,16,64s per backoff.go)gocraft/work dead pool, SkipDead:false (verified worker_service.go:62) — dead jobs persist in Redis indefinitely; no vacuum/TTL is configured today (REV-8), surfaced as a Datadog metric (worker_service.go:150-151)inherits worker pool (SERVICE_WORKER_MAX_FAILS/concurrency from config)note_id + recipient_sso_id (mandatory; no timestamp — REV-7)10s per Notification-Service callafter final retry → dead pool + log cdp_note_mention_notify_failed {reason, retry_count}; drop (note already saved)

Detail 2.F.1 — Responsibility Boundary Matrix

Step (execution order)Owning squad / serviceInbound triggerOutbound effectFailure handlerPRD anchor
1. Compose + insert anchorCDP FE / Mobileagent picks userHTML with anchor (web data-user-id / mobile href sso_id)picker error → savable w/o mentionS01, S02
2. Parse + validate + sanitize + persistCDP contact-servicePOST/PUT …/notesnote + mentioned_user_ids storeddrop-and-warn / 422S01/AC-3, S04
3. Enqueue notify per not-yet-notified mentionCDP contact-servicenote committed (create/edit)MentionNotifyJob on Redis; append to notified_user_idsenqueue fail → log, note savedS03/AC-1
4. Dispatch notificationCDP worker → Notification/Platformjob consumedPOST /api/v1/notifications/crmretry×3 → log failedS03/ERR-1
5. Render + deliverNotification/Platformservice receivesweb center (qontak-unified in hub/hub-chat) + FCM (mobile-qontak-crm), auto for crm originplatform-ownedS03/AC-2
6. Tap-throughMobile Squad (route) / FE (web)recipient tapsweb: notes tab; mobile: external_url opens web URLunmapped origin → toast (avoided via Decision 9)S03/AC-2b

Steps 4–6 are now verified against notification-service + qontak-unified-component

  • mobile-qontak-crm (2026-06-22). The only remaining reconcile item is the narrowed OQ-1 (reuse mention(2) category vs add a CDP-specific one) and the host-app deep-link gaps (OQ-17), both small and scoped — not blocking the BE dispatch build.

Detail 2.F.2 — State Surface Contract

EntityState field / eventDefaultUpdated byRead viaStale window
ContactNotementioned_user_ids (current mentions)[] (absent on old docs)CreateNote/UpdateNoteGET …/notesnone (read-after-write same doc)
ContactNotenotified_user_ids (append-only notified set — Decision 4)[] (absent on old docs)CreateNote/UpdateNote (union with to_notify; never pruned)server-internal (dedup; not exposed in API)none (read-after-write same doc)
Notificationdelivery (sent/failed)noneMentionNotifyJobunified Notification Center (qontak-unified in hub/hub-chat) + mobile One Notif V2async; visible via metrics/logs only to author

Detail 2.G — Cross-Layer Contract Verification

EndpointBE response schemaFE/Mobile expected schemaMatch?Gaps
POST/PUT …/notes{ contact_note_id, mentioned_user_ids[], dropped_mentions?[] , ... } (snake_case)FE CustomerStore maps snake→camel; needs to read mentioned_user_ids + dropped_mentionspartialFE/mobile do not consume the new fields yet — must add mapping (FE CustomerStore, mobile response model). Tracked as §4.D chunks.
Company user list[{ sso_id, full_name, avatar?, status }] (snake_case, full_name/avatar nullable)FE picker needs sso_id + null-safe full_name/avataryesnull-safety: display full_name ?? email (verified full_name can be null on the BE sql.NullString per PRD; FE must null-check)
Single-user detail (preview card, S05)LaunchpadUserDetailResponse{full_name, email, sso_id, staff_level, status} (verified qontak_launchpad.go:545-556)card needs name, email, staff level, team(s)partialresponse has no teams[] and no avatar → teams resolved separately or omitted, avatar = initials (OQ-22). 404 = inactive/not-found (OQ-20).
Mention anchor (web)parser expects a[data-user-id][data-mention]composer emits exactly that; DOMPurify must ADD_ATTRpartialDOMPurify allow-list required at save+render or identity is stripped — blocking FE chunk
Mention anchor (mobile)parser expects a[href=".../users/{sso}/edit_user"] (D-9)mapper must emit sso_id (Decision 8)partialuntil Decision 8 lands, mobile emits id not sso_id → parser would store a non-SSO id; both halves must ship together
Notification body (title/description)dispatch sends server-constructed strings only ("{Author} mentioned you in a note" / "On contact {Name}") — never user-authored note HTMLweb NotificationCenter (qontak-unified-component) + mobile One Notif V2 render these fieldsyes (low risk)The note's user HTML is sanitized server-side (bluemonday, Decision 3) and is not placed in the notification body, so no user-controlled markup reaches the center. Contract assumption: the platform-owned center treats title/description as text. CDP dispatch must not interpolate raw note content into these fields.
Web deep-link click (click_action_url)dispatch sets https://{host}/customers/{contact_id}?tab=notes (server-constructed host template)useNotificationCenter.redirectToCRMLink → MFE RemoteContactRouter → notes tabpartial (OQ-17)host/MFE must parse ?tab=notes + un-gate the Notes tab (chunk 10). {host} from trusted config, not request input (no open-redirect).

Every "partial" has a concrete closing chunk in §4.D; none is silently accepted.

Detail 2.H — End-to-End Data Flow

Agent picks @user (FE: getUsers/GetListMemberCdp) → composer inserts anchor (web data-user-id / mobile href sso_id) → Save → POST/PUT /iag/v1/contacts/{id}/notes → contact_notes_handler decode + validate → ContactNotesService: parse anchors (D-9) → validate sso_ids via IUserService (company-scoped) → bluemonday sanitize → drop invalid/self → repo insert/$set MongoDB (note + mentioned_user_ids + append to notified_user_ids) → response (incl. mentioned_user_ids, dropped_mentions) → FE updates note list, renders chip (DOMPurify ADD_ATTR) → (async) IJobEnqueuer enqueues MentionNotifyJob per not-yet-notified mention (to_notify = parsed − notified_user_ids − self; create & edit — Decision 4) → worker POST /api/v1/notifications/crm → web Notification Center (qontak-unified in hub/hub-chat) + One Notif V2 (mobile-qontak-crm, auto FCM) → recipient taps → web: click_action_url → MFE RemoteContactRouter → /customers/{id}?tab=notes / mobile: external_url → mark read (platform-owned).

  • Side effects: analytics events (cdp_note_mention_*); notification delivery records (permanent — D-6).
  • Ownership per step: §2.F.1.
  • Preview-card read flow (S05, separate + read-only): Agent hovers chip (data-user-id) → UserStore.getUserDetail(sso_id) [session cache] → Launchpad get_by_sso_id → MpPopover card (name/email/staff level/team(s); 404 → inactive state; 5xx → fallback). No write, no notification, no change to GET …/notes.

Detail 2.I — Scope Boundaries

  • BE create: mention/parser.go, mention/policy.go (bluemonday), MentionNotifyJob handler + registration, api/notification_client.go. BE modify: repository/contact_notes/base.go (+mentioned_user_ids + notified_user_ids fields), service/contact_notes/contact_notes_service.go (create/update + notified-set diff), payload/contact_notes_request.go/response, api/qontak_launchpad.go or a new validate method, worker bootstrap. BE NOT touched: attachments, delete, ownership, permission keys, soft-delete.
  • FE create: mention picker component (or MpAutocomplete reuse), chip styles, preview-card component (S05) + UserStore.getUserDetail(ssoId). FE modify: NoteInput.vue (typeahead + anchor insert + save DOMPurify opts), NotesList.vue (render DOMPurify opts + chip + MpPopover wrap for the card), CustomerStore.ts (read new response fields), maybe UserStore filter. FE NOT touched: attachments, audio/file flows, note delete, and the note read/write path (the card is read-only — Decision 14).
  • Mobile modify: mention_toolbar_botton.dart CDP mapper (sso_id), CDP note response model (read mentioned_user_ids if needed), notification payload handling already routes via external_url. Mobile NOT touched: Quill codec, CRM (non-CDP) mapper, notification routing core.
  • Shared modules: mention_toolbar_botton.dart is shared CRM/CDP — change is isolated to the CDP _mapMemberToMention (l.398-402); CRM mapper (l.246-251) untouched. Impact: CDP mentions only.

Detail 2.J — Asset Inventory (frontend half)

AssetTypeSourceFormat & sizesPath
Mention chip stylingCSS (no new asset)design-system tokens (pixel3)n/aNotesList.vue scoped styles
Mention/typeahead iconiconpixel3 (reuse)SVGfrom @mekari/pixel3

No net-new image/lottie/font assets. Mobile reuses the existing MpIcons.interfaceEssential.textEditorMention (verified detail_note_screen.dart:872).


3. High-Availability & Security

HA narrative. The note write is a single synchronous Mongo write (unchanged availability profile). Notification dispatch is decoupled onto the Redis-backed worker (Decision 2): if the Notification Service or Redis is degraded, the note still saves and the dispatch retries/logs — the user-facing write path never depends on the external service. If the typeahead source is down, the composer degrades gracefully (note still savable without a mention).

Performance Requirement

  • Frontend: typeahead P95 ≤ 500ms (debounced, client-filtered); a11y AA for the listbox + chip. Bundle budget (REV-11): the picker component adds little; if the Decision-12 Tiptap fallback is taken, budget the added @tiptap/* weight (~50–80KB gzipped, lazy-load the notes composer route so it does not regress the customers-detail initial bundle) and assert it in npm run build size output. The pixel3-native path adds no new dependency.
  • Backend: note write ≤ 2s P95 (parse + validate + sanitize must stay well under; validation is one company-user lookup, batched). Notification dispatch off the request path. Per-call Notification-Service timeout 10s in the worker.
  • Scalability: validation lookups should be batched (one company-user fetch, filter client-side) to avoid N calls per note. Worker concurrency inherits the existing pool.

Monitoring & Alerting

Observability events (PRD §11) — emitted with slog (verified logging lib):

EventTriggerProperties
cdp_note_mention_addednote created or edited with ≥1 mention (edit may add/re-mention — D-11)note_id, contact_id, company_sso_id, mention_count, new_mention_count, author_sso_id, eventmention_count = total mentions on the note; new_mention_count = newly-notified this save (`
cdp_note_mention_notify_sentnotification dispatched per mentionnote_id, recipient_sso_id, notification_id
cdp_note_mention_notify_faileddispatch failed after retriesnote_id, recipient_sso_id, reason, retry_count
cdp_note_mention_invalidmention SSO UUID failed company validationnote_id, company_sso_id, invalid_sso_id, action
cdp_note_mention_picker_failedtypeahead/user-list call failedcompany_sso_id, platform
cdp_note_mention_preview_failed (RFC-local, FE, S05 — not in PRD §11)preview-card user-detail lookup failed (5xx/timeout)company_sso_id, sso_id, platform
  • Alerts: cdp_note_mention_notify_failed rate > 5% → Slack #cdp-ops; picker failure rate > 10% → investigate the user-source dependency (PRD §11).
  • Tracing: OTel/Datadog spans already propagate into the worker via job_enqueuer.go:59-71 (traceparent/tracestate).
  • SLO: notify success ≥ 99% (PRD §12); dashboard owner CDP Squad.

Logging

  • BE structured fields via slog.*Context(ctx, msg, slog.Any(...)); trace id in meta.trace_id (verified envelope).
  • PII: do not log note content or full names; log sso_ids (pseudonymous) and ids only. note body never logged.

Security Implications

  • Threat model: stored-XSS via a crafted mention anchor or note HTML; cross-tenant mention/notification; injection via data-user-id.
  • Server-side sanitization (Decision 3): bluemonday allow-list run on every write — the authoritative control (FE DOMPurify is convenience). Allow-list = formatting tags emitted by the toolbar + a[href][data-user-id][data-mention] only; everything else stripped (S04/ERR-2).
  • DOMPurify allow-list (FE): ADD_ATTR: ['data-user-id','data-mention'] at both save (NoteInput.vue:460) and render (NotesList.vue:104); without it, identity is stripped (verified) — but the allow-list must NOT widen to script-bearing attributes.
  • Tenant isolation: mention validation is company-scoped (company_sso_id from the authenticated context, verified require_permission_middleware.go:97); cross-company ids are dropped + logged (NEG-1).
  • Input validation: mentioned_user_ids must be well-formed UUIDs (malformed → drop, Decision 5); count ≤ 10; total HTML ≤ 10,000 chars.
  • Preview card (S05) — read-only, low risk: resolves a stored sso_id to company-scoped directory data (name/email/staff level/team(s)) for a viewer who already has customers_customernotes_view (so already sees the note + its mentions). No new write path, no new cross-system egress, no user-controlled input to the lookup (the sso_id comes from the note's own sanitized anchor). Company scope is enforced by the same session auth as the typeahead; a cross-company sso_id resolves to a generic 404 (inactive/not-found), never leaking another tenant's directory.
  • SSRF: click_action_url is server-constructed from a trusted host template, not user input.
  • Secrets: notification-service X-Api-Key via the existing config/Vault mechanism (hashicorp/vault/api present); never logged. The NotificationClient (§4.D chunk 5) must not log the full outbound request/headers on error — log a sanitized status only (e.g. notification dispatch failed: <status>), never the X-Api-Key or body.
  • Static analysis: make secgosec (verified Makefile:142-145).
  • Compliance: see §3.D.

Role × Endpoint Authorization Matrix

RoleEndpoint(s)MethodsTenant scopeUI visibilityConstraintAudit
Agent (author)…/notesPOST (add), PUT (manage)own company@ affordance when flag ON≤10 mentions; owner-only update (verified service:196)note write + cdp_note_mention_added
Viewer…/notesGET (view)own companychip visible, no @read-onlynone
Mentioned recipient(external notification stream)n/aownsees notificationcannot retract (D-6)cdp_note_mention_notify_sent
System/serviceinternalvalidate + dispatchenforces company scopen/aserver authoritativecdp_note_mention_invalid

Detail 3.A — Failure Mode Catalog (merged)

SurfaceFE behavior on failureBE response on failureCode-shape consistency
Typeahead source down"Couldn't load people — try again"; savable w/o mentionn/a (FE→user source)yes
Note too long / >10 mentionsinline validation error422 NOTE_TOO_LONG / TOO_MANY_MENTIONSyes (FE reads resp_desc.en)
Invalid/cross-company mentionwarn chip on dropped id200 + dropped_mentions[] (Decision 5)yes
Notification Service 5xx/timeoutsilent to author (note saved)async retry×3 → log cdp_note_mention_notify_failedyes
Crafted anchor (XSS)n/asanitized server-side; only allow-listed markup persistsyes

Detail 3.A.1 — Branch & Skip Catalog

Branch triggerWhere checkedDownstream effectAuditUser-visible?
cdp_notes_mention_enabled OFFFE composer + BE service entryno parse/validate/dispatch; note behaves as today (NEG-2)noneno (no @ affordance)
Recipient == authorBE service (before notified-set check)exclude from notify (S03/ERR-2)noneno
Mention already in notified_user_ids (already notified / removed-then-re-added)BE service notified-set check (Decision 4)no notification (idempotent per note — OQ-14)noneno
Invalid SSO UUIDBE service validationdrop id; note saves (Decision 5)cdp_note_mention_invalidyes (warn chip)
Private note + mention (OQ-9)n/a — no server-side privacy model exists (verified base.go/read.go)notify normally (no branch)noneno — resolved, not a real branch

Detail 3.B — Error Response Catalog (BE)

Envelope (verified pkg/http/response.go): { resp_code, resp_desc:{id,en}, meta }.

EndpointError codeHTTPMessage (en)WhenUser-facing?
POST/PUT …/notesNOTE_TOO_LONG422"Note content exceeds maximum length of 10,000 characters" (verified string)total HTML > 10000yes
POST/PUT …/notesTOO_MANY_MENTIONS422"A note can mention at most 10 people"> 10 mentionsyes
POST/PUT …/notesBAD_REQUEST400validation failuremalformed bodyyes
PUT …/notes/{id}FORBIDDEN403"access denied: only note owner can update" (verified)non-owner updateyes
…/notes/{id}NOT_FOUND404"note not found or access denied" (verified)wrong contact/companyyes

Detail 3.C — Error Message Catalog (FE)

ErrorUser message (i18n key TBD)SurfaceUser-facing?
picker load failed"Couldn't load people — try again"inline dropdownyes
no match / deleted user (Launchpad generic 404 — S01/ERR-2, v1.4)"No matching people" (same copy whether a typo or a since-deleted user — Launchpad has no deleted-vs-non-existent flag, OQ-20)inline dropdown empty stateyes
mobile offline picker"People list unavailable — check your connection"bottom sheetyes
dropped mention"{name} couldn't be mentioned" warn chipinlineyes
note too long"Note is too long"inlineyes
preview card — inactive/not-found (S05/ERR-1, generic 404)inactive-avatar + last-known @Name (Figma 15091-448947); no error toastpreview cardyes
preview card — lookup failed (S05/ERR-2, 5xx/timeout)"Couldn't load details" (name only); note stays readablepreview cardyes

Detail 3.D — Compliance & Data Governance

FieldClassificationLegal basisRetentionEncryptionAccess auditRight-to-delete
note (HTML)user-generated content (may contain PII)UU PDP / legitimate interestsame as note (unchanged)at rest (Mongo) + TLS in transitnote read permsdeletes with the note (soft-delete unchanged)
mentioned_user_idspseudonymous user identifier (PII-linked)UU PDPsame as noteat rest + transitas abovedeletes with the note
notification recorddelivery metadataUU PDPplatform-owned (Notification Service)platform-ownedplatform-ownednot recallable once sent (D-6) — documented limitation
notification body (title/description)contact name + author name egress to notification-serviceUU PDPplatform-owned (permanent record)platform-ownedplatform-ownedPII-egress note: description = "On contact {Name}" sends the contact name to notification-service in a permanent, non-recallable record. Lower risk than first thought — the recipient is always a same-company user who already has customers_customernotes_view (so already sees the note + contact); confirm with infosec the body may carry contact context (no private-note carve-out needed — OQ-9 resolved). 2026-06-22 addendum: verification confirmed FCM (mobile push) fan-out is automatic for crm-origin notifications (service.go:47-49) with no skip_fcm opt-out — so the contact name may surface on a device lock-screen push on mobile-qontak-crm. This does not change the legal basis (recipient is the same authorized same-company user) but must be called out in the infosec review as an explicit, accepted egress. If unacceptable, mitigate by making description generic (e.g. "You were mentioned in a note") — a one-line dispatch change.

Trigger present (user-generated content + PII identifiers + cross-system delivery) → infosec review required before AGREED (approver gate).

Detail 3.E — Accessibility

  • Typeahead: WCAG AA; role="listbox"/option; keyboard arrow/enter/escape; aria-activedescendant; visible focus.
  • Mention chip: role="link", accessible name @{full_name}, contrast ≥ 4.5:1 (token-driven).
  • Mobile: chip deferred (Decision 7); plain text is natively accessible.

4. Backwards Compatibility and Rollout Plan

Compatibility

  • BE: request shape unchanged (note may now carry mention anchors — existing clients sending plain notes are unaffected). Response adds mentioned_user_ids (+ optional dropped_mentions) — additive, non-breaking. Old notes have no field → treated as empty.
  • FE/Mobile saved state: none affected.
  • Cross-layer: the parser must tolerate notes with no anchors (no-op). Notes authored before the feature render exactly as today.

Rollout Strategy

  • Deploy order: BE first (parser + sanitizer + field + dispatch behind the flag, dark) → then FE (composer + chip) and Mobile (mapper). Rationale: the BE must accept and correctly ignore anchors before clients emit them; the response field is additive so FE/mobile can deploy after.
  • Feature flag: cdp_notes_mention_enabled (default OFF, per-company, Ops-provisioned). Single logical flag gating all layers; FE/mobile read it to show the @ affordance, BE reads it to enable parse/validate/dispatch (NEG-2). Mobile compose additionally remains gated behind existing flag_one_notification for the notification half (verified default OFF).
  • Stages: Stage 1 internal QA company → Stage 2 closed beta 3–5 companies (ideally CRM-migrated) → Stage 3 progressive per-company GA (PRD §10).
  • Stop conditions: notify success < 95%, picker failure > 10%, or any stored-XSS finding → halt + flag OFF.
  • Rollback: flag OFF (instant) → notes revert to plain behavior; stored mentioned_user_ids are harmless if left. Per-layer rollback in §4.E.
  • PIC + timeline: CDP Squad; per-stage TBD.

Detail 4.A — Cross-Layer Rollout Compatibility Matrix

ScenarioFEBEWorks?Mitigation
Pre-deployOldOldyesbaseline
Backend firstOldNew (flag OFF/dark)yesBE ignores anchors when none sent; flag OFF → no dispatch
Frontend firstNewOldnoFE would emit anchors the old BE stores unsanitized + never parses → avoid: deploy BE first (chosen order)
Both deployedNewNewyestarget state
Backend rollbackNewOldnoroll back FE first, or flag OFF; new BE field is additive so data is safe
Frontend rollbackOldNewyesBE still parses any stored anchors; chips degrade to plain <a> (DOMPurify strips data-* on old FE)

Detail 4.B — Configuration Contract

LayerEnv var / flagTypeDefaultRequiredProvisionerSecret?
BE+FE+Mobilecdp_notes_mention_enabledbool (per-company flag)OFFyesOps (flag service)no
BENOTIFICATION_SERVICE_URLstringyesconfig/Vaultno
BENOTIFICATION_SERVICE_API_KEYstringyesVaultyes
BENOTIFICATION_SERVICE_TIMEOUTduration10snoconfigno
BECDP_NOTES_MENTION_MAXint10noconfig (Decision 6)no
Mobileflag_one_notificationboolOFF (verified)yes (notif half)flag serviceno

Detail 4.C — Test Plan (commands sourced from each repo)

LayerCommand (source)What it proves
BE unitmake testgo test -race -tags dynamic ./internal/... ./config/... (source: contact-service/Makefile:79-84)parser (both forms), notified-set diff (incl. remove→re-add no re-notify — OQ-14), validation drop (incl. 404-deleted), sanitizer policy, enqueue-per-not-yet-notified (create + edit)
BE lintmake lintstaticcheck ./... (Makefile:137-140)static analysis clean
BE securitymake secgosec (Makefile:142-145)no new security findings on the write path
BE buildmake build (Makefile:45-48)compiles with new deps (bluemonday)
BE migrationmake migrate-up (Makefile:173)optional mention index applies (if added)
FE unitnpm run testvitest (source: qontak-customer-fe/package.json:16)chip renders; anchor carries sso_id; DOMPurify keeps data-* with ADD_ATTR; picker states
FE unit (preview card, S05)npm run testvitestgetUserDetail resolves + caches by sso_id; hover renders name/email/staff level/team(s); 404 → inactive state (no throw); 5xx → "Couldn't load details"; teams row omitted when unresolved (OQ-22)
FE lintnpm run linteslint . (package.json:18)lint clean
FE buildnpm run buildnuxt build (package.json:6)builds (MFE)
Mobile unitmelos run testflutter test test/main_test.dart (source: mobile-qontak-crm/melos.yaml:97-99; FVM: melos run test-fvm)CDP mapper emits ssoId in href; render fallback shows @Name
Mobile analyzemelos run analyzeflutter analyze (melos.yaml:77-79)analyzer clean
Cross-layermanual/integration: web POST note w/ data-user-id and mobile POST w/ href → both yield identical mentioned_user_idsD-9 dual-parse correctness

No FE e2e/typecheck script exists (verified) — cross-layer is covered by BE table-tests (both anchor forms) + FE vitest; an e2e harness is a follow-up, not a blocker.

Detail 4.D — Agent Execution Plan

OrderLayerChunkFiles to modify/createCommandsAcceptance criteria
1BEAdd mentioned_user_ids + append-only notified_user_ids fields + optional indexrepository/contact_notes/base.go; db/migrations/0NN_contact_notes_mention_index.up.json/.down.jsonmake build, make migrate-upboth fields compile; round-trip bson; absent on old docs → empty; index applies + rolls back
2BEmention.Parser (dual-form D-9) + max-10 + UUID validationinternal/app/service/mention/parser.go (+ _test.go)make testunit: web data-user-id form + mobile href sso_id form both → same []sso_id; malformed dropped; >10 → error
3BEServer-side sanitizer policy (Decision 3 concrete spec)internal/app/service/mention/policy.go; go.mod (+bluemonday)make test, make secgolden test: allow-listed formatting + mention anchor survive; <script>, onerror=, javascript:/data: href, style=, and extra attrs all stripped (Decision 3 spec)
4aBEAdd IUserService.GetActiveUsersBySsoIds (REV-5/OQ-13)internal/app/service/launchpad/user_service.go (+ interface L21-29); wraps existing GetUsersByUserSsoIds (api/qontak_launchpad.go:311-367, returns SsoID+Status) and keeps Status=="active"make testunit: given sso_ids + company, returns only active company members; non-members/inactive excluded
4bBEWire parse+validate+sanitize+persist into create/update; drop-and-warn; notified-set diff (Decision 4)service/contact_notes/contact_notes_service.go; payload/contact_notes_request.go (+response fields)make testcreate stores valid ids + dropped_mentions + seeds notified_user_ids; edit computes to_notify = parsed − notified_user_ids − self, appends to set; self filtered; remove→re-add does not re-notify (OQ-14 unit test); invalid/cross-company + 404-deleted dropped via 4a
5BENotificationClient (POST /api/v1/notifications/crm, payload finalized — Decision 10) + MentionNotifyJob (enqueue per not-yet-notified mention)internal/app/api/notification_client.go; internal/app/service/.../mention_notify_job.go; worker registration; job_enqueuer call sitemake test, make buildrequest sends sso_id+title+description+notif_type=1+notif_category=2+click_action_url with X-Api-Key; job enqueued per id in to_notify (not-yet-notified) on create and edit; 5xx → retry×3 → cdp_note_mention_notify_failed; note unaffected; idempotency key (note_id+recipient_sso_id) dedupes retries + concurrent PUTs
6FEDOMPurify ADD_ATTR at save + render; read new response fieldsNoteInput.vue:460; NotesList.vue:104; CustomerStore.tsnpm run test, npm run lintvitest: data-user-id/data-mention survive sanitize; dropped-mention warn chip shows
7FEMention typeahead + chip (after OQ-7 spike + Figma)new MentionPicker (or MpAutocomplete); NoteInput.vue; chip styles NotesList.vuenpm run test, npm run buildtypeahead lists company users (sso_id, null-safe name); selecting inserts anchor; chip renders
8MobileCDP mapper emits sso_id; render fallback verifiedmention_toolbar_botton.dart (_mapMemberToMention l.398-402)melos run test, melos run analyzeDart test: emitted href = .../users/{ssoId}/edit_user; list shows @Name (no raw HTML)
9MobileConfirm external_url notification route opens web note URL (mobile-qontak-crm only)(verify-only; notification_item_v2_mixin.dart, bottom_navigation_screen_mixin.dart:253-308)melos run analyzemention notification with origin=external_url opens click_action_url; no "cannot redirect" toast; mobile-qontak-chat not targeted
10FEWeb deep-link to notes tab (OQ-17)qontak-customer-fe: parse ?tab=notes in CustomerActivityV2.vue; un-gate the Notes tab from isStagingnpm run test, npm run buildnavigating to /customers/{id}?tab=notes (from the unified-center click) auto-selects the Notes tab
11FEUserStore.getUserDetail(ssoId) + session cache (S05, Decision 14)features/customers/store/UserStore.ts (new method calling Launchpad get_by_sso_id; cache keyed by sso_id)npm run test, npm run lintvitest: returns {full_name,email,staff_level,status}; second call for the same id hits cache (no refetch); 404 → not-found sentinel; 5xx → error surfaced (not thrown to the view)
12FEMention preview card on hover (S05) — after chunk 7 (chip) + Figma 15091-119388/448947new preview-card component; wrap chip in MpPopover in NotesList.vue (reuse :31-46); inactive/not-found + error states; teams via getTeams() or omit (OQ-22)npm run test, npm run buildvitest: hover renders name/email/staff level/team(s); 404 → inactive state (no throw); 5xx → "Couldn't load details"; dismiss leaves note readable; teams row omitted gracefully when unresolved

Order rule: BE chunks 1–3, 4a, 4b, 5 land (flag OFF/dark) before FE chunk 7 and Mobile chunk 8 (which emit anchors). Chunk 4b depends on 4a (the validation method). Chunk 5's payload is now finalized (/crm, Decision 10) — no longer gated on an external contract. Chunk 7 is gated on Figma (OQ-14); the OQ-7 spike only picks pixel3-native vs the Tiptap fallback (Decision 12) — it does not block the chunk. Chunk 10 (OQ-17) is independent and can land any time before web GA. Chunks 11–12 (preview card, S05) depend on chunk 7 (a rendered chip to hover) + chunk 6 (DOMPurify keeps data-user-id so the card can read the sso_id); chunk 12 also needs the S05 Figma (which exists — 15091-119388/448947 — so it is not gated on OQ-14). Chunk 11 (the store method) can land independently of the UI. The mobile preview card is out of scope for v1 (no chip — Decision 7/14).

Detail 4.E — Verification & Rollback Recipe

  • Pre-merge (per layer, in order):
    • BE: 1) make lint 2) make sec 3) make test 4) make build
    • FE: 1) npm run lint 2) npm run test 3) npm run build
    • Mobile: 1) melos run analyze 2) melos run test
  • Post-deploy signals:
    • cdp_note_mention_notify_sent / (sent+failed) ≥ 99% (CDP dashboard).
    • cdp_note_mention_picker_failed rate < 10%.
    • No new gosec / stored-XSS findings; note-write P95 ≤ 2s (Datadog contact-service dashboard).
  • Rollback (deploy-order-aware):
    1. Toggle cdp_notes_mention_enabled OFF for affected companies (instant; stops parse/validate/dispatch + hides @).
    2. If FE is the problem: revert the FE PR (BE field is additive — safe).
    3. If BE is the problem: roll back FE first, then the BE PR; the additive field is harmless if left.
    4. Confirm cdp_note_mention_notify_failed returns to baseline and note-write P95 ≤ 2s in the next 15 min.

Detail 4.F — Resource & Cost Notes

  • Compute: negligible API delta; worker handles dispatch (existing pool). DB: one small array per note. Network egress: one Notification-Service call per new mention (≤10/note) + one company-user lookup per note write (batched). No new infra components.

5. Concern, Questions, or Known Limitations

Carried from PRD §15 (OQ-1…OQ-14), updated with verification outcomes. Numbering note: OQ-1…OQ-12 map 1:1 to the PRD; OQ-13…OQ-22 are RFC-local (verification/architecture discoveries with no PRD counterpart), so RFC OQ-13/OQ-14 are not the PRD's OQ-13/OQ-14 — the two new PRD items are folded in below as OQ-20 (← PRD OQ-13, not-found semantics) and OQ-21 (← PRD OQ-14, remove→re-add re-notify). OQ-22 is new this revision (PRD v1.6 CHG-004 preview-card team/avatar resolution gap). Blocking items gate §7.

#TypeQuestionStatus after verification / mitigationOwner
OQ-1Narrowed (verification)notif_type/notif_category/event_type for a CDP note-mention; web Notification Center parityVERIFIED 2026-06-22: use POST /api/v1/notifications/crm, notif_type="1" (general), notif_category="2" (mention); the web NotificationCenter already renders mention. Only remaining product choice: reuse the generic mention(2) category or add a CDP-specific category in notification-service (small platform change) for analytics/filtering. Non-blocking for v1 (reuse works).CDP + Notification Platform
OQ-2OpenCompany-wide vs team-scoped candidatesDefault company-wide (Decision 11). Team-scoping deferred.PM
OQ-3Resolved (Decision 5)Invalid mention → reject vs dropDrop-and-warn adopted.PM + Eng
OQ-4Resolved (Decision 6)Max mentions per note10 adopted (config CDP_NOTES_MENTION_MAX).PM + Eng
OQ-5Resolved (Decision 9)Mobile deep-link into a CDP noteexternal_url + web URL for v1; native route deferred. Confirm host/URL w/ Mobile before beta.Notification + Mobile
OQ-6Resolved (verification)Server ?query= search vs reuse existing getUsersVERIFIED 2026-06-22: Launchpad GET /private/users?query=&statuses[]=active&sso_ids[]=&company_sso_id= exists (ILIKE on email+full_name, user_handler.go:459, list_by_company.go:120-128). v1 may still reuse getUsers+client filter (Decision 11); escalate to the server search if P95 > 500ms. Caveat: avatar not in the list response → use initials/full_name.Launchpad
OQ-7Resolved-with-fallbackDoes MpRichTextEditor (pixel3) support a custom @ mention extension?UNVERIFIED — pixel3 external package. Decision 12 now pre-commits a custom Tiptap fallback, so the web composer is executable regardless. The ≤3-day spike only decides pixel3-native (preferred) vs Tiptap — it no longer blocks.CDP FE + Pixel 3
OQ-8Resolved (verification)company_sso_id (string) → Notification organization_id (UUID) mappingVERIFIED 2026-06-22 — concern dissolved. organization_id on the notification request is an optional *uuid.UUID metadata field (notification-service/notification_handler.go:67-83), not the recipient and not required — the recipient is always sso_id. And company_sso_id is itself a UUID (Launchpad models.go:15-27 — both company id and sso_id are UUIDs). So organization_id can be set to company_sso_id directly or omitted. No special mapping or extra lookup is needed.CDP Eng + Platform
OQ-9Resolved (verification)Private note + mention → notify or suppress?No server-side note privacy/visibility model exists. Verified: ContactNote has no is_private/visibility field (base.go:25-36); the read filter (read.go:32-44) scopes only on contact_id+company_sso_id+soft-delete; the response permission object is edit/delete rights only (contact_notes_request.go:69-72). Any user with customers_customernotes_view already sees all the company's notes for a contact. Decision: notify normally — there is no privacy to honor. The web "Private" label is non-functional display text; revisit only if a real privacy model ships later.PM
OQ-10Resolved (verification)Mobile id = SSO UUID?No — mention.id is the CDP user id (UUID), distinct from sso_id; but sso_id IS present in MemberCdpResponse. Decision 8: emit sso_id from the CDP mapper.Mobile + CDP Eng
OQ-11Resolved (Decision 7)Mobile chip vs plain-textPlain-text fallback for v1 (no HTML renderer — verified).PM + Mobile
OQ-12Resolved (Decision 8)Mobile codec change vs backend dual-parseBoth: backend dual-parses (D-9) and mobile mapper emits sso_id (no Quill codec change needed).CDP Eng + Mobile
OQ-13Resolved (verification)Does IUserService expose a company-scoped active-user validation method?No active-membership method exists today, but the base to extend is verified. GetUserListBySsoIds(companySsoId, userSsoIds) (user_service.go:166-174) is company-scoped and its client GetUsersByUserSsoIds (qontak_launchpad.go:311-367) already returns SsoID+Status. Resolution: add a thin GetActiveUsersBySsoIds(ctx, companySsoId, ssoIDs) ([]string, error) that wraps it and keeps Status=="active". (GetUserNamesBulk can't be reused — it discards Status.)CDP Eng
OQ-14Open (REV-4) — sole remaining blockerWeb Figma frames for the typeahead picker + mention chip are not yet created.Independent of OQ-7 (a resolved spike still leaves no design). Blocks only §4.D chunk 7. Create frames before the web composer chunk.Design + CDP FE
OQ-15Resolved (verification)Do both note route groups echo the new response fields?Yes, automatically. Verified: both the canonical /{contact_id}/notes group and the deprecated /notes/{contact_id} group in rest_router.go route to the same ContactNotesHandler.CreateNote/UpdateNote (not separate implementations). A response-shape change in the shared handler/ContactNoteResponse appears on both groups (and on the web FE's deprecated-path calls) with no per-group work.CDP Eng
OQ-16Resolved (re-pinned for PRD v1.5)cdp_note_mention_added count semantics + FE i18n keysPRD v1.5 splits the counts: mention_count = total mentions on the note; new_mention_count = newly-notified this save (`to_notify
OQ-17Open (REV-13) — internal, smallWeb notification deep-link into the CDP notes tabThe unified NotificationCenter click navigates via click_action_url (useNotificationCenter.ts:35-44), but inside the MFE: (a) CustomerActivityV2.vue does not parse ?tab=notes to auto-select the Notes tab, and (b) the Notes tab is gated behind isStaging===true. Two small qontak-customer-fe changes (§4.D chunk 10); no change to qontak-unified-component or the hosts. Gates clean web tap-through (not the BE dispatch).CDP FE
OQ-18Resolved (verification)Which mobile app receives CDP mention notificationsmobile-qontak-crm only (owns the CDP customer page + notes + crm-origin FCM). mobile-qontak-chat excluded (no CDP notes; chat/room-only routing). See Decision 13.Mobile + CDP
OQ-19Open (REV-14) — infosec ackAuto-FCM exposes the contact name on a mobile lock-screen pushnotification-service pushes FCM automatically for crm-origin notifications with no skip_fcm opt-out (service.go:47-49), so description="On contact {Name}" may appear on a mobile-qontak-crm lock-screen. Legal basis unchanged (recipient is an authorized same-company viewer). Needs explicit infosec acceptance before AGREED, or adopt the generic-description mitigation (§3.D). Folds into the existing infosec gate; does not block build.Infosec + CDP
OQ-20Resolved (← PRD OQ-13, v1.4)Not-found user semantics: Launchpad returns a generic 404 for any unresolved user — a wrong keyword / no match and a deleted user are indistinguishable (no per-user deleted flag). Accept the generic not-found, or ask Launchpad for a deleted/status flag?Accept the generic not-found for v1. Typeahead empty state shows "No matching people" (S01/ERR-2); server-side validation treats a 404 the same as any invalid id → drop-and-warn (S04/ERR-3, same path as ERR-1, Decision 5). The system cannot show a distinct "user was deleted" message today. Revisit a Launchpad status flag only if differentiating deleted users becomes a real need. No build blocker.PM + Launchpad
OQ-21Resolved (← PRD OQ-14, v1.5) — by Decision 4Remove→re-add re-notify policy: should a mention that is removed and later re-added notify again, or match CRM (never re-notify once notified for a note)?Match CRM — Decision 4 keys dedup on an append-only notified_user_ids set that is never pruned, so a removed-then-re-added mention is already in the set and is not re-notified. A user is notified at most once per note lifetime. Verified by a §4.D chunk-4b unit test.PM + Eng
OQ-22Open (PRD v1.6 CHG-004) — non-blockingThe preview card (S05) needs name, email, staff level, and team(s), but the verified single-user detail LaunchpadUserDetailResponse (qontak_launchpad.go:545-556) carries no teams[] and no avatar. How to resolve a mentioned user's team(s) for the card — (a) map via the existing company getTeams() list client-side, (b) ask Launchpad to add teams[]/avatar to get_by_sso_id, or (c) ship the card without the teams row for v1?Default (c)→(a): ship the card with name/email/staff level now (degraded, not broken); add the teams row via getTeams() mapping if a user→team lookup is available, else omit it. Avatar falls back to MpAvatar initials (same as OQ-6). Escalate (b) to Launchpad only if teams on the card becomes a hard requirement. Does not block the card MVP (chunks 11–12) or the note path.CDP FE + Launchpad

Review ledger. The full finding ledger (REV-1…REV-10, severity/status) lives in the companion review rfc-notes-mention-user-review.md (cycle R1, score 7.5/10, verdict HOLD). Post-R1 disposition of findings: REV-1→OQ-1 (open — external Notification contract); REV-2→OQ-8 (narrowedGetCompanyDetail lacks the UUID, still needs Platform); REV-3→Decision 12/OQ-7 (fallback chosen — see below); REV-4→OQ-14 (open — Figma, external/Design); REV-5 resolved (OQ-13 — method spec'd); REV-6 resolved (OQ-9 — no privacy model); REV-7 fixed (idempotency key); REV-8 resolved (§2.F retention/retry); REV-9 resolved (OQ-15 — shared handlers); REV-10 resolved (OQ-16). 2026-06-22 update: with the previously-missing repos now in the workspace, REV-1 (OQ-1) is verified/closed (contract grounded — /crm, Decision 10) and REV-2 (OQ-8) is dissolved (organization_id optional; company_sso_id is a UUID). Of 10 findings: 8 resolved, 0 narrowed-open, only REV-4 (OQ-14 Figma) remains as a true external blocker (plus the optional spike half of REV-3). New this cycle: OQ-17 (web deep-link, internal/small) + OQ-18 (mobile app scope, resolved).

Known limitations: concurrent edits to the same note are last-write-wins on the note body (no optimistic lock today — verified); the timestamp-free notification idempotency key (note_id + recipient_sso_id, REV-7) prevents double-notify even across concurrent PUTs, but cannot recover a lost body edit. Notifications are permanent (no retract — D-6). Mobile mentions display as plain text in v1. Mention validity is checked at note write time; if a recipient is deactivated in the seconds between the write and the async dispatch, the notification is still sent (point-in-time validation, not re-checked at dispatch) — accepted for v1, low impact. Mobile delivery is mobile-qontak-crm only; mobile-qontak-chat users receive no CDP mention push (Decision 13). The contact name appears in the mobile lock-screen push (no skip_fcm opt-out — see §3.D). The per-note notified set (notified_user_ids, Decision 4) is append-only and never pruned, so a removed-then-re-added mention is not re-notified (OQ-21) — and, symmetrically, a note authored before this feature has an empty set, so its first post-feature edit seeds the set from its then-current mentions and may notify those (pre-existing) mentions once on that first edit (accepted, low impact — the flag is off until rollout so few legacy notes are affected). The mention preview card (S05) ships web-only in v1; mobile has no tappable chip (Decision 7) so the card is deferred with the mobile chip (Decision 14). The web card may omit the teams row if a user→team mapping is not resolvable client-side, since LaunchpadUserDetailResponse has no teams[] (OQ-22) — name/email/staff level always render; the card never errors the note view.


6. Comment logs

DateComment(s) FromAction Item(s)
2026-06-18RFC author (rfc-starter)Initial draft from PRD v1.2; all repo anchors re-verified against worktrees; PRD line numbers corrected (see Source Verification).
2026-06-18rfc-reviewer (cycle R1, commit 2d43cb2)Score 7.5/10, verdict HOLD; ledger REV-1…REV-10 in rfc-notes-mention-user-review.md.
2026-06-18RFC author (post-R1)Addressed rfc-reviewer R1 findings (verified against contact-service): REV-7 idempotency key fixed (note_id + recipient_sso_id, timestamp-free + mandatory); REV-8 retry/retention grounded; REV-5 GetActiveUsersBySsoIds spec'd (chunk 4a); REV-6 resolved (no server-side privacy model); REV-9 resolved (shared route handlers); REV-10 mention_count pinned; REV-3 fallback chosen (Tiptap, Decision 12 → Partial); REV-2/OQ-8 narrowed (GetCompanyDetail lacks UUID). Remaining: REV-1 (OQ-1 notif contract) + REV-4 (OQ-14 Figma) — external. These will flip to fixed on the next review cycle.
2026-06-18rfc-reviewer (cycle R2, commit 5883ba8)Score 8.0/10 (+0.5), verdict HOLD; 0 dangling decisions. R1 internal findings REV-5/6/7/8/9/10 → fixed, REV-3 → partial. New minors: REV-11 (Tiptap fallback dep + bundle budget), REV-12 (last_updated granularity). Ledger in rfc-notes-mention-user-review.md.
2026-06-18RFC author (post-R2)Fixed REV-11: added the Tiptap fallback to §1 Dependencies + a bundle budget in §3 Performance. REV-12 (last_updated granularity): revision binding is by commit SHA (reviewed_commit) since same-day edits share a date; will bump last_updated on cross-day material edits.
2026-06-22RFC author (AUGFLOW-012 re-verification)Notification architecture re-checked against newly in-workspace repos (notification-service, qontak-unified-component, hub, hub-chat, qontak-launchpad, mobile-qontak-chat). Corrections: (1) ingestion endpoint is POST /api/v1/notifications/crm, not /chat (the /chat endpoint rejects non-inbox types) — Decision 10 + §2.4 rewritten; (2) title is required (was missing); (3) no skip_fcm field — mobile FCM fan-out is automatic for crm origin; (4) OQ-8 dissolvedorganization_id is optional *uuid.UUID metadata and company_sso_id is itself a UUID; (5) OQ-1 narrowed/verified — taxonomy confirmed (general(1)/mention(2)), web NotificationCenter already renders mention; (6) OQ-6 resolved — Launchpad GET /private/users?query=&statuses[]=active exists. Web unified-notification + MFE architecture added (Decision 13): NotificationCenter.vue in qontak-unified-component is hosted in hub/hub-chat; qontak-customer-fe embeds via module federation (RemoteContactRouter) at /customers/**. Mobile scope resolved (OQ-18): deliver to mobile-qontak-crm only; mobile-qontak-chat excluded. New OQ-17 (web deep-link to notes tab — two small qontak-customer-fe fixes, chunk 10). Net effect on §7: the two prior external notification blockers are cleared; only OQ-14 (Figma) remains.
2026-06-22rfc-reviewer (cycle R3)Score 8.5/10 (+0.5), rating Agentic-Ready, verdict PROCEED (was R2 8.0/HOLD). REV-1 (notification contract) + REV-2 (organization_id) + REV-11 (Tiptap inventory) + REV-12 (last_updated) all → fixed; ACV 6.5→8.5 releases the cap. New: REV-13 (OQ-17 web deep-link, minor internal), REV-14 (OQ-19 lock-screen PII, infosec ack). Only OQ-14 (Figma, REV-4) blocks, and only chunk 7. Ledger + scorecard in rfc-notes-mention-user-review.md.
2026-06-30RFC author (sync to PRD v1.6)Absorbed PRD v1.6 — CHG-004 mention preview card (story NOTE-MENTION-S05). Grounded against in-workspace contact-service + qontak-customer-fe: the verified single-user detail IUserService.GetUserDetailLaunchpadUserDetailResponse (qontak_launchpad.go:223-224,545-556) returns full_name/email/staff_level/status but no teams[]/avatar, and qontak-customer-fe already has an MpPopover pattern (NotesList.vue:31-46) + UserStore (no getUserDetail yet). Added Decision 14 (FE-direct, lazy, client-cached getUserDetailMpPopover card; mobile card deferred with the mobile chip per Decision 7) + index row; new OQ-22 (teams/avatar gap — degraded-but-not-broken fallback). Propagated S05 through §1 overview/deps/design-refs/derivation, §1.A forward+reverse traceability, §1.B/1.C, UI/Consumer/Role/PRD-Section coverage, §2.0 anchors/contracts/patterns/reading-order/source-verification/design-map, §2.4 user-detail API row, a new §2.2 preview-card sequence, §2.A/2.B/2.C/2.G/2.H/2.I, §3.C error catalog + optional cdp_note_mention_preview_failed event + security note, §4.D chunks 11–12 + order rule, §4.C test row. Renumbered the guard rail NOTE-MENTION-S05-NEG → S06-NEG everywhere to match PRD v1.6. Note: the preview card has real Figma (15091-119388/448947), so unlike the composer it is not gated on OQ-14. Mermaid: 1 new sequence block added (preview-card resolution). Validated by the sanctioned offline fallback (mmdc/npx fetch failed offline, as in prior sessions): pitfall scan confirms no ; in any message/Note, no </> ambiguity, and — being a sequenceDiagram — flowchart bracket/paren rules don't apply; its constructs (actor, parens-in-as-alias, the --x failure arrow, nested alt/else) all match already-shipping diagrams in this same file (e.g. the happy-path sequence's participant Q as Redis (gocraft/work) and --x arrows). No existing diagram was edited.
2026-06-26RFC author (sync to PRD v1.5)Synced the RFC from the PRD v1.2 baseline to PRD v1.5. (1) Idempotent notification (D-4/D-11/OQ-14): rewrote Decision 4 from a currently-stored-set diff to an append-only persisted notified_user_ids set (to_notify = parsed − notified_user_ids − self), so edit notifies newly-added mentions but a user is notified ≤1× per note lifetime and a removed-then-re-added mention is not re-notified — matches CRM find_or_create_by. Added notified_user_ids to the Go struct + erDiagram + §2.3 + §2.F.2; propagated through the branch/skip flow, state machine, §2.D/§2.E/§2.F/§2.H, and §4.D chunks 1/4b/5 + §4.C. (2) Edit re-mention + add (D-11): added S01/AC-4 + S02/AC-4 to traceability + per-story map (composer/button enabled on edit). (3) Not-found semantics (PRD v1.4): added S01/ERR-2 + S04/ERR-3 (Launchpad generic 404; deleted vs non-existent indistinguishable) + new OQ-20; FE error catalog "No matching people" row. (4) Observability: cdp_note_mention_added now fires on create or edit with new_mention_count + event (re-pinned OQ-16). (5) Added OQ-21 (← PRD OQ-14, resolved by Decision 4) and the OQ-numbering note. Retained the grounded /crm endpoint (PRD's /chat is stale vs in-workspace notification-service). No diagram-count change; the 4 edited mermaid blocks (component flowchart, state machine, happy-path sequence, branch/skip flowchart) + erDiagram were re-checked against the parser-pitfall list — all new labels/notes are ;-free and special chars are double-quoted (mmdc/mermaid could not run live this session: offline, npx fetch failed, no local install).

7. Ready for agent execution

Ready for agent execution: NEARLY — one external blocker remains (web Figma frames, OQ-14). The 2026-06-22 re-verification dissolved the two prior external notification blockers: with notification-service, qontak-unified-component, hub/hub-chat, and qontak-launchpad now in the workspace, the notification contract (OQ-1), the company-UUID mapping (OQ-8), and the user-search dependency (OQ-6) are verified against code — not assumed. The BE dispatch payload is now fully finalizable. The remaining hard external gate is OQ-14 (Figma for the web composer), which blocks only §4.D chunk 7. A new small internal gap (OQ-17, web deep-link to the notes tab) is scoped inside qontak-customer-fe and does not block the BE/mobile work.

Gates status:

  • ✅ Infrastructure Topology — deployment + per-service diagrams present and all downstream nodes now verified in-workspace (Mongo, Redis/gocraft, Launchpad, notification-service, web unified center, MFE embed, mobile-qontak-crm).
  • ✅ Technical Decisions — ADR blocks for Decisions 1–11; Decision 12 (web editor) Partial (custom-Tiptap fallback chosen); Decision 13 added (web unified-center rendering + MFE embed + mobile app scoping). Decision 4 rewritten for PRD v1.5 — append-only notified_user_ids set delivers idempotent ≤1/note notification incl. remove→re-add (D-4/D-11/OQ-14), superseding the v1.2 stored-set diff. Decision 14 added for PRD v1.6 — preview-card data source (FE-direct, lazy, client-cached getUserDetailMpPopover; mobile deferred). Minimum coverage addressed (storage, sync/async, sanitize/third-party, consistency, multi-tenancy, reuse/new; per-status n/a justified).
  • ✅ §1 PRD-to-Schema Derivation + Design References (FE half, with "design pending" surfaces flagged).
  • ✅ Detail 1.C Per-Story Change Map — every story one row, FE+BE columns filled, verifiable AC.
  • ✅ Repo Reading Guide + Source Verification complete — every anchor opened, evidence cited, PRD line drift corrected; unverifiable external facts moved to Open Questions.
  • ✅ Mermaid diagrams — topology, component, ER (Mongo doc), state, branch/skip, sequences (happy + 3 failure paths + preview-card resolution, S05).
  • ✅ Document model + APIs (extended endpoints tagged; new dependency call tabled with timeout/retry); Cross-Layer Contract Verification (partials each have a closing chunk).
  • ✅ Data Integrity, Concurrency, Async Job spec, Responsibility Boundary, Branch & Skip, error catalogs.
  • ✅ Cross-Layer Rollout Matrix (deploy BE-first chosen; "Frontend first" explicitly avoided); Configuration Contract; Agent Execution Plan (files+commands+assertable AC from repo configs); Verification & Rollback Recipe.

Blocking gaps (why not yet YES):

  1. OQ-14 — web Figma frames pending (external, Design-owned). No design exists for the typeahead picker + chip. Gates the web composer chunk (§4.D #7) regardless of the editor path. This is the only remaining hard external blocker.

Newly resolved by the 2026-06-22 re-verification (no longer blocking):

  • OQ-1 (notification contract). Endpoint = POST /api/v1/notifications/crm (not /chat), notif_type="1", notif_category="2", recipient sso_id, X-Api-Key; web center already renders mention. Only a non-blocking product choice remains (reuse vs add a CDP category).
  • OQ-8 (company UUID). Dissolved — organization_id is optional metadata and company_sso_id is itself a UUID; no mapping needed.
  • OQ-6 (user search). Launchpad GET /private/users?query=&statuses[]=active verified to exist.
  • OQ-18 (mobile app). mobile-qontak-crm only; mobile-qontak-chat excluded (Decision 13).

New scoped internal gap (not blocking BE/mobile):

  • OQ-17 — web deep-link to the notes tab. Two small qontak-customer-fe changes (parse ?tab=notes; un-gate the Notes tab). No change to qontak-unified-component or the hosts.

Resolved since R1 (still holding):

  • OQ-7 / Decision 12 → Partial. Executable Tiptap fallback chosen (§4.D #7); spike only decides pixel3-native vs Tiptap.
  • OQ-13 (REV-5)IUserService.GetActiveUsersBySsoIds specified; §4.D chunk 4a.
  • OQ-9 (REV-6) — no server-side privacy model; notify normally.
  • OQ-15 (REV-9) — both note route groups share the same handler.
  • REV-7/REV-8 — idempotency key fixed + mandated; worker retry/retention grounded (§2.F).
  • OQ-16 (REV-10) — re-pinned for v1.5: mention_count = total, new_mention_count = newly-notified, event = create/edit; i18n keys mechanical.

Unblocked now (can start immediately): BE chunks §4.D #1–#3, #4a, #4b, and #5 in full (payload now finalized — /crm, no longer pending an external contract), FE render/DOMPurify chunk #6, Mobile chunks #8–#9, the OQ-17 deep-link fixes in qontak-customer-fe, and the preview-card data layer #11 (UserStore.getUserDetail). The preview-card UI #12 (S05) is also unblocked — unlike the composer, it has real Figma (15091-119388/448947), so it is not gated on OQ-14; it only sequences after the chip renders (#7). Web composer chunk #7 still needs OQ-14 (Figma). The mobile preview card is out of v1 (no chip — Decision 7/14). CHG-004 / S05 adds no new external blocker — its one open item, OQ-22 (teams/avatar), has a degraded-but-shippable fallback. Re-run this gate to YES once Figma (OQ-14) lands.

rfc-reviewer cycle R3 (2026-06-22): score 8.5/10 — Agentic-Ready — verdict PROCEED. The notification-contract blockers (REV-1/REV-2) are closed and ACV rose 6.5→8.5; the only remaining blocker is OQ-14 (Figma, REV-4), which gates just chunk 7. New findings REV-13 (OQ-17 web deep-link) and REV-14 (OQ-19 lock-screen PII ack) are minor/internal. Full report: rfcs/rfc-notes-mention-user-review.md.