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 — reasonrather 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 labelIDEAlives only in the Metadata table.Verification provenance. All
repo:path:lineanchors 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 embedqontak-customer-feas an MFE),qontak-launchpad, and the second mobile appmobile-qontak-chat. The previouslyUNVERIFIEDnotification 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 noskip_fcmfield;organization_idis 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'sCrm::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 newnotified_user_idsdoc 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 (newS01/ERR-2,S04/ERR-3; RFC OQ-20). (3) Observability —cdp_note_mention_addednow fires on create or edit and carriesnew_mention_count+event. The grounded/crmendpoint correction (vs the PRD's stale/chat) is retained — the RFC is the engineering doc and the in-workspacenotification-serviceis 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_idsvia 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 lookupIUserService.GetUserDetail→LaunchpadUserDetailResponsecarriesfull_name/staff_level/statusbut noteams[]and noavatar(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 renumberedNOTE-MENTION-S05-NEG → S06-NEGto 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
| Field | Value | Notes |
|---|---|---|
| Status | IDEA | Human label IDEA/RFC/AGREED/ABANDON; YAML status: carries the linter enum (draft). |
| DRI | TBD (CDP Squad eng lead) | RFC owner (frontmatter dri). |
| Type | full-stack | FE (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 Squad | Primary author(s). |
| Reviewers | CDP Squad (BE+FE), Mobile Squad, Notification/Platform Squad | Tech reviewers across affected squads. |
| Approver(s) | TBD tech lead + TBD infosec approver | Infosec approval required before AGREED (stores user-authored HTML server-side — D-5). |
| Submitted Date | 2026-06-18 | ISO-8601. |
| Last Updated | 2026-06-30 | ISO-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 Release | 2026-Q3 | Carried from source PRD. |
| Target Quarter | 2026-Q3 | Advisory; from PRD/initiative README. |
| Source PRD | ../prds/prd-notes-mention-user.md | Mirrors 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
- Overview (Design References — FE half; PRD-to-Schema Derivation — BE half; traceability; decisions; per-story map)
- 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)
- High-Availability & Security
- Backwards Compatibility and Rollout Plan (cross-layer rollout matrix, Agent Execution Plan, Verification & Rollback Recipe)
- Concern, Questions, or Known Limitations
- Comment logs
- 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, persistmentioned_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 storedsso_idto the user's name/email/staff level/team(s) on hover, reusing the existingMpPopoverpattern (verifiedNotesList.vue:31-46) + a newUserStore.getUserDetail(ssoId)(Decision 14). This app is embedded as a Micro-Frontend (module federation,RemoteContactRouter) insidehub/hub-chatat/customers/**(verifiedqontak-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 viaPOST /api/v1/notifications/crm(notif_typegeneral(1), notif_categorymention(2)), not/chat(which only accepts inbox type3) (verifiedroute.go:57-58,notification_handler.go:225,313). Recipient bysso_id; auth viaX-Api-Key(verifiedflexible_auth_middleware.go:14-40). It fans out to the web Notification Center and to mobile FCM automatically forcrm-origin notifications (verifiedservice.go:47-49).qontak-unified-component(Vue 3, pnpm workspace) — the web Notification Center (NotificationCenter.vue), imported into the Qontak Chat FE hostshub/hub-chatnavbar (verifiedhub-chat/layouts/components/TheNavbar/TheNavbar.vue:79). It already supports thementioncategory and renders CRM notifications; CRM-origin notifications navigate viaclick_action_url(verifiedhub-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 (verifiedcrm_note/.../note_screen.dart,mention_toolbar_botton.dart). It receivescrm-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-chat— out of scope (no CDP customer-notes feature; its notification routing is chat/room-only — verifiedapp.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=, verifieduser_handler.go:459,list_by_company.go:120-128). Also the preview-card detail source: the single-user lookupGET /private/users/get_by_sso_id?sso_id=→LaunchpadUserDetailResponse{full_name, email, sso_id, staff_level, status, ...}(verified viacontact-serviceqontak_launchpad.go:223-224,545-556). Caveat (grounded): that response carries noteams[]and noavatar— 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 reliability —
notify_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.)
- No migration of legacy CRM mentions (owned by the separate Legacy CRM Notes → CDP migration PRD).
- No new notification surface — reuse
notification-service(POST /api/v1/notifications/crm) + the web Notification Center inqontak-unified-component(rendered inhub/hub-chat) + mobile One Notification V2 (mobile-qontak-crm). No notification UI is built in this RFC. - No mention of non-users (no contacts, teams, external emails).
- No rich-text editor overhaul — adds the mention token only.
- 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). - No cross-company / cross-tenant mention — candidates scoped to the note's
company_sso_id. - 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-chatis entirely out of scope — it has no CDP customer-notes feature and must not receive these notifications (Decision 13). - No changes to the web Notification Center component itself (
qontak-unified-component) — it already renders thementioncategory; this RFC only emits a correctly-shaped notification and ensures theclick_action_urldeep-link resolves (the host-app routing gaps are tracked as OQ-17).
Related Documents
| Title | Link / path | What this RFC took from it |
|---|---|---|
| Source PRD — Mention User in CDP Notes v1.6 | ../prds/prd-notes-mention-user.md | All 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 Service | https://jurnal.atlassian.net/wiki/spaces/QON/pages/49791664344 | Notification 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 Component | https://jurnal.atlassian.net/wiki/spaces/QON/pages/50203885766 | Web 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/50603491444 | Mobile 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 workspace | Parity model only; UNVERIFIED — repo not in workspace. Used as conceptual precedent (PRD Appendix A), not as a code anchor here. |
Assumptions
- VERIFIED 2026-06-22.
notification-serviceis reachable fromcontact-servicewith a serviceX-Api-Key(flexible_auth_middleware.go:14-40), accepts a recipient bysso_id, and supportsclick_action/click_action_urldeep links (notification_handler.go:67-83). The correct ingestion endpoint for a CDP mention isPOST /api/v1/notifications/crm(notif_type=general(1),notif_category=mention(2)), not/chat(Decision 10). No longer a blocking unknown. - VERIFIED 2026-06-22. A company-scoped active-user list/search is available. FE
UserStore.getUsers()exists;contact-servicevalidates vialaunchpad.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. @mekari/pixel3'sMpRichTextEditorcan 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).- VERIFIED 2026-06-22 — assumption corrected. The notification payload's
organization_idis an optional*uuid.UUIDmetadata field (notification_handler.go:67-83), not the recipient and not required. The note'scompany_sso_idis itself a UUID (Launchpadmodels.go:15-27), so it can populateorganization_iddirectly or be omitted. The earlier blocking concern (no company UUID available) is resolved (OQ-8). - Plan gating (Growth + Enterprise only) and the
cdp_notes_mention_enabledflag are provisioned by Ops; flag wiring is in scope, flag provisioning is not.
Dependencies
| Dependency | Owning team | Deliverable | Status | Blocking? |
|---|---|---|---|---|
notification-service POST /api/v1/notifications/crm | Notification/Platform | Stable endpoint + X-Api-Key; recipient by sso_id; click_action_url; notif_type=general(1)/notif_category=mention(2); organization_id optional | verified 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-chat | Platform / FE Platform | Renders mention notification; routes click_action_url for CRM origin | verified 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-chat | CDP / FE Platform | RemoteContactRouter mounted at /customers/**; ?tab=notes deep-link resolves | partial — 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 Squad | external_url tap-through (Decision 9 / D-10); auto-FCM for crm origin | exists — 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 / CDP | Company active-user list with sso_id + full_name + status; ?query= search variant | verified 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 / CDP | Single-user detail by sso_id with full_name + email + staff_level + status + teams[] for the card | partial — 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 / FE | MpPopover hover card on the rendered chip + UserStore.getUserDetail(ssoId) + inactive/not-found state | needs-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 + sanitizer | CDP Squad | mentioned_user_ids field, dual-form anchor parser (D-9), server-side HTML sanitization, async dispatch | needs-building (this RFC) | YES |
qontak-customer-fe composer + renderer | CDP / FE | Typeahead (OQ-7 spike), DOMPurify allow-list (save+render), mention chip | needs-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 extension | conditional (new npm dep; see §3 bundle budget — REV-11) | NO (fallback path) |
mobile-qontak-crm anchor alignment + render + route | Mobile / CDP | Emit 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 surface | Figma / design link | Frame name | Design system version | Design QA contact | Notes |
|---|---|---|---|---|---|
| Web — mention typeahead picker | n/a — design pending | TBD | @mekari/pixel3@1.0.10-dev.0 (verified package.json:24) | TBD | Blocker — 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 pending | TBD | @mekari/pixel3@1.0.10-dev.0 | TBD | Chip = styled non-editable @Name. Token set TBD. |
| Web — mention preview card (CHG-004 / S05) | Figma 15091-119388 | preview card (name/email/staff level/team(s)) | @mekari/pixel3@1.0.10-dev.0 (reuse MpPopover) | TBD | Design exists — preview card on hover; reuses MpPopover (NotesList.vue:31-46). Not gated on OQ-14. |
| Web — preview card inactive / not-found state | Figma 15091-448947 | inactive / not-found card | @mekari/pixel3@1.0.10-dev.0 | TBD | Launchpad generic 404 (deleted or non-existent) → inactive-avatar + last-known name; never errors the note view (same constraint as OQ-20). |
| Mobile — mention preview card | n/a — deferred (v1) | — | mobile DS | — | Deferred with the mobile chip (Decision 7 / 14) — no tappable target in v1. |
| Mobile — mention picker (bottom sheet) | n/a — already ships | existing CDP member picker | mobile DS (Quill toolbar) | TBD | Compose UI already ships (MpMentionToolbarButtonX); no new design needed for compose. |
| Mobile — mention display in note list | n/a — design pending | TBD | mobile DS | TBD | v1 = graceful plain-text fallback (Decision 7 / OQ-11); chip deferred. |
| Web + mobile — notification surface | N/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-servicepersists notes in MongoDB (verified:go.mongodb.org/mongo-driver,db/migrations/*.json). "Schema" here is thecontact_notesdocument shape (Go struct + bson tags), not SQL DDL.
| PRD-described entity / attribute / rule | Persisted as (collection.field) | Exposed via (endpoint / event) | Enforced where | Source |
|---|---|---|---|---|
| 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_ids | ContactNotesService.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 endpoints | new mention.Parser invoked in service before persist | PRD §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 write | rejected/dropped per Decision 5 (drop-and-warn) | service → launchpad.IUserService company-user lookup | PRD §9 S04, §8#2 |
| Note content ≤ 10,000 chars including mention markup | contact_notes.note string, validate:"required,max=10000" (verified base.go:29) | 400/422 on overflow | validator.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 count | 422 if exceeded (or drop-extras — Decision 6) | new check in mention.Parser/service | PRD 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 Service | service computes to_notify = parsed_valid − notified_user_ids − self; enqueues per id; appends them to notified_user_ids | PRD §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/a | the notified set is append-only, so a re-added id is already present → skipped | PRD OQ-14 |
| Author never self-notified (S03 ERR-2) | n/a | n/a | service filters author_sso_id out of recipients | PRD §9 S03 ERR-2 |
Company identity for notification organization_id | contact_notes.company_sso_id (verified base.go:29) → Launchpad company.id UUID | notification payload | mapping/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 live | Launchpad single-user detail GET /private/users/get_by_sso_id (FE-direct via UserStore.getUserDetail) — not the note write path | FE preview card (lazy on hover); Decision 14 | PRD §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 id | FE section / component | BE 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 + anchor | n/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 catalog | n/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 state | n/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/a | service 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 row | OQ-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 state | n/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 / dependency | PRD 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 IUserService | S04/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 surface | Consumer | Required reads (BE) | Required writes (BE) | FE component | Status surface |
|---|---|---|---|---|---|
| Note composer (web) | web | company user search (typeahead) | POST/PUT /iag/v1/contacts/{id}/notes (extended) | NoteInput.vue | mentioned_user_ids echoed in note response |
| Notes list (web) | web | GET /iag/v1/contacts/{id}/notes (existing) | n/a | NotesList.vue | mention chip rendered from stored anchor |
| Mention preview card (web — CHG-004/S05) | web | Launchpad single-user detail get_by_sso_id (FE-direct, lazy on hover) | n/a (read-only) | NotesList.vue chip + MpPopover card | name/email/staff level/team(s); inactive/not-found state |
| Mention preview card (mobile) | mobile | n/a — deferred v1 | n/a | n/a — no chip in v1 (Decision 7) | deferred with the mobile chip (Decision 14) |
| Note composer (mobile) | mobile | CDP member picker (GetListMemberCdp, existing) | POST/PUT …/notes | detail_note_screen.dart + mention_toolbar_botton.dart | mentioned_user_ids |
| Note list (mobile) | mobile | GET …/notes (existing) | n/a | note_screen.dart / note_item.dart | plain-text fallback (Decision 7) |
| Notification (web) | web | Notification Center reads (/notif/v1/notifications) | n/a — covered by dispatch | NotificationCenter.vue (qontak-unified-component in hub/hub-chat) | notif_category=mention(2), origin=crm |
| Notification (mobile) | mobile | One Notification V2 reads | n/a | NotificationV2Screen (mobile-qontak-crm only) | origin=external_url tap-through (Decision 9) |
Role Coverage
| PRD role | Authorization mechanism | Endpoints permitted (BE) | UI surface visibility (FE) | Cross-tenant? | Audit trail |
|---|---|---|---|---|---|
| CS/Sales Agent (author) | chi RequirePermissionMiddleware + perm key | create: customers_customernotes_add; update: customers_customernotes_manage (verified rest_router.go:154,157) | composer @ affordance shown when flag ON + has create/manage | no (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_view | no | cdp_note_mention_notify_sent |
| Viewer (read-only) | customers_customernotes_view (verified rest_router.go:153) | GET …/notes | sees mention chip + preview card on hover (CHG-004/S05 — company-scoped directory data only); no @ affordance | no | n/a |
| System (server-side validation) | service-internal | validation + dispatch | n/a | enforces company scope | cdp_note_mention_invalid |
PRD Section Coverage
| PRD § | Title | Where covered |
|---|---|---|
| 2 | Adjustment Context | §1 Overview |
| 3 | One-liner + Problem | §1 Overview |
| 4 | Target Users + Persona | §1.A Role Coverage |
| 5 | Non-Goals | §1 Out of Scope |
| 6 | Constraints | §2 Technical Decisions, §3 HA/Security, §4 Config |
| 7 | Feature 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 |
| 8 | API & Webhook Behavior | §2.4 APIs, §2.2 sequences, §3.A failure |
| 9 | System Flow + Stories + ACs | §1.A traceability, §1.C per-story map, §2.2 |
| 10 | Rollout | §4 Rollout |
| 11 | Observability | §3 Monitoring & Alerting |
| 12 | Success Metrics | §1 Success Criteria |
| 13 | Dependencies | §1 Dependencies, §2.F.1 responsibility |
| 14 | Key Decisions + Alternatives | §2 Technical Decisions (ADR) + §1.B |
| 15 | Open Questions | §5 Open Questions (OQ-1…OQ-12) |
| Appendix A | Grounded 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.
| # | Decision | Chosen option | Layer | §2 block |
|---|---|---|---|---|
| 1 | Mention storage shape | Resolved tag mentioned_user_ids []string (SSO UUID) on the Mongo note doc (D-1) | BE | Decision 1 |
| 2 | Sync vs async notification | Async via existing IJobEnqueuer worker (gocraft/work); non-blocking (D-4, D-8) | BE | Decision 2 |
| 3 | Server-side sanitization | Add bluemonday allow-list on the note write path (D-5); new-with-justification | BE | Decision 3 |
| 4 | Re-notification policy | Notify 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) | BE | Decision 4 |
| 5 | Invalid mention handling | Drop-and-warn (note saves; invalid id excluded) (D-3 / OQ-3) | BE+FE | Decision 5 |
| 6 | Max mentions per note | 10 (OQ-4) | BE | Decision 6 |
| 7 | Mobile render | Graceful plain-text fallback for v1 (D-? / OQ-11) | Mobile | Decision 7 |
| 8 | Mobile mention encoding | Change the CDP mapper to emit sso_id in the href; backend dual-parses (D-9 + refined OQ-10/OQ-12) | Mobile+BE | Decision 8 |
| 9 | Mobile notification route | external_url + web click_action_url for v1 (D-10 / OQ-5); mobile-qontak-crm only | Mobile | Decision 9 |
| 10 | Notification delivery channel + contract | Reuse 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) | BE | Decision 10 |
| 11 | Typeahead source | Reuse existing company-user list; client-side filter; Launchpad ?query= search now verified available (OQ-6 resolved) | FE | Decision 11 |
| 12 | Web editor mention trigger | Partial — fallback = custom Tiptap editor; OQ-7 spike only decides pixel3-native vs Tiptap | FE | Decision 12 |
| 13 | Web rendering + mobile app scoping | Web 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+Mobile | Decision 13 |
| 14 | Preview-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) | FE | Decision 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 id | Title | Layer scope | FE changes | BE changes | Composite AC ids | Acceptance criteria (verifiable) | RFC anchors |
|---|---|---|---|---|---|---|---|
NOTE-MENTION-S01 | Mention a teammate (Web) | FE + BE | NoteInput.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 CSS | mention.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-2 | vitest: 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-S02 | Mention a teammate (Mobile) | FE + BE | mention_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_ids | S02/AC-1, AC-2, AC-3, AC-4, ERR-1 | Dart 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-S03 | Mentioned user notified (Web & Mobile) | FE + BE | mobile: 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 map | S03/AC-1, AC-2, AC-2b, AC-3, ERR-1, ERR-2 | Go 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-S04 | Only valid company users mentionable | BE | n/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 anchors | S04/AC-1, ERR-1, ERR-2, ERR-3 | Go 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-S05 | Preview a mentioned user on the notes list (Web; mobile deferred) | FE | web: 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-2 | vitest: 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-NEG | No cross-company / non-user mentions (guard rail) | FE + BE | flag OFF → no @ affordance | flag OFF → no parsing/validation/dispatch; cross-company id rejected | S06-NEG/NEG-1, NEG-2 | Go 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-backedgocraft/workworker (internal/app/service/job_enqueuer.go:45-83,make run-workerMakefile: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-componentNotificationCenter.vue; importedhub-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 []stringon 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_mentionscollection (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
MentionNotifyJobon the existingIJobEnqueuer(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,178enqueues 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.
- Pros: durable retry; survives restarts; OTel trace propagation already built in (
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.
- Pros: authoritative; allow-lists exactly the mention anchor (
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 — allowp,br,strong,em,b,i,u,s,h1..h3,ul,ol,li,a. aattributes: allowhrefanddata-user-id+data-mentiononly; striptarget,rel,style,class,on*.hrefscheme:AllowURLSchemes("https")for normal links; additionally allow the relative mention form^\.\./.*\/edit_user$(mobile anchor); rejectjavascript:,data:, and all other schemes.data-user-idvalue 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 fromprevious_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 tonotified_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[]stringfield 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-idanchor. Cons: the Quill→HTML codec does not supportdata-*attributes (PRD OQ-12) — larger codec change. - Option B — backend resolves
{cdp_user_id} → sso_idat parse time (lookup via the user service). Cons: extra per-mention lookup; needs a{id}→sso_idmap. - Option C — change only the CDP mapper (
_mapMemberToMention, l.398-402) to emitsso_idin the href path (.../users/${mention.ssoId}/edit_user), and have the backend dual-parse both the webdata-user-idform 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_idis already in the response.
- 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;
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_url — no CDP/Contact360/note key
(verified qontak_app_route.dart:133-141). There are CRM fallback branches
(crmPersonId→contactDetail, 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_urlandclick_action_url = https://{host}/customers/{contact_id}?tab=notes. Pros: works today via the verifiedexternal_urlpath (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 sameclick_action_urlserves 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
NotificationClientininternal/app/api/mirroringQontakLaunchpadClient(heimdall httpclient), callingPOST /api/v1/notifications/crmwithX-Api-Key. Pros: reuses the verified outbound-client pattern (qontak_launchpad.go:38, heimdallWithHTTPTimeout); 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-Keyheader (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 structvalidatetag) — 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(enumOPEN_URL/OPEN_APP) +click_action_url(string) =https://{host}/customers/{contact_id}?tab=notes.organization_id(*uuid.UUID, optional metadata) — populate withcompany_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(genericmap[string]interface{}, optional).- No
skip_fcmfield exists. FCM (mobile push) fan-out is automatic forcrm-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 carriessso_idfor the webdata-user-idanchor.
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.
- Web: emit the mention notification with
origin=crm,notif_category=mention(2), andclick_action_url=https://{host}/customers/{contact_id}?tab=notes. No change toqontak-unified-component— it already renders the category. The CDP work is to ensure the deep-link lands on the notes tab inside the MFE. - Mobile: deliver to
mobile-qontak-crmonly (automatic viacrm-origin FCM, Decision 9). Do not targetmobile-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(verifiedcontact-service/internal/app/api/qontak_launchpad.go:223-224,545-556;GET /private/users/get_by_sso_id?sso_id=) returnsfull_name,email,sso_id,staff_level,status— but carries noteams[]and noavatar. The FE already fetches the company team list separately (UserStore.getTeams()→IAG_LAUNCHPAD_URL, verifiedUserStore.ts:62) and renders avatars from initials viaMpAvatar(verifiedNotesList.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 newUserStore.getUserDetail(ssoId)(mirroring the existinggetUsers/getTeamsPinia pattern), caches the result bysso_idfor the session, and renders anMpPopovercard (reusing the verifiedNotesList.vue:31-46popover).- 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).
- 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 (
- Option B — BE enriches the note-read response.
GET …/notesresolves everymentioned_user_idsentry 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
| Layer | Path | Why the agent reads it | What pattern it teaches |
|---|---|---|---|
| BE | internal/app/service/contact_notes/contact_notes_service.go | The note create/update logic to extend | CreateNote(L42)/UpdateNote(L182); validateCreateNoteInput; 10k check L272 |
| BE | internal/app/repository/contact_notes/base.go | The ContactNote Mongo doc to add a field to | struct L26-36; SetDefaults() L51-62; bson tags |
| BE | internal/app/repository/contact_notes/{create,read,update}.go | How notes are written/read in Mongo | mongo.Create; bson.M filters; soft-delete |
| BE | internal/server/rest_router.go | Where note routes + perms are registered | r2.Route("/{contact_id}/notes") L151-159; perm keys |
| BE | internal/app/handler/contact_notes_handler.go | Handler shape; request decode + validate | CreateNote L278; h.valid.Struct(req) L309; company/owner extraction |
| BE | internal/app/payload/contact_notes_request.go | Request/response structs to extend | CreateContactNoteRequest{Note,Attachments}; ContactNoteResponse |
| BE | internal/app/api/qontak_launchpad.go | Outbound client pattern to mirror for both validation + notification | QontakLaunchpadClient heimdall, NewQontakLaunchpadClient(rootURL,auth,timeout) L38 |
| BE | internal/app/service/job_enqueuer.go | The async pattern to reuse for dispatch | IJobEnqueuer.EnqueueJob L45-83; OTel propagation L59-71 |
| BE | internal/app/handler/contact_sync_handler.go | A live example of enqueuing a job | EnqueueJob(...CreateContactJobName...) L142,178 |
| BE | internal/pkg/http/{handler,response,default_error}.go | Error envelope + success helper | NewJSONResponse; BaseResponse{resp_code,resp_desc{id,en},meta}; ErrBadRequest/... |
| FE | features/customers/detail/components/Notes/components/NoteInput/NoteInput.vue | Composer to add @ typeahead + chip | MpRichTextEditor :options=editorOptions L155-166; save sanitize L460 |
| FE | features/customers/detail/components/Notes/components/NotesList/NotesList.vue | Render 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 |
| FE | features/customers/store/CustomerStore.ts | Note API calls | createNote POST /v1/contacts/notes/${id}; payload {note,attachments} |
| FE | features/customers/store/UserStore.ts | Typeahead 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.go | The 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 |
| FE | features/customers/properties/views/.../ModalFieldSetup.vue + UserSelectionStore.ts | Existing MpAutocomplete user-picker precedent | reusable typeahead-of-users pattern |
| Mobile | features/crm_note/.../detail_note/detail_note_screen.dart | CDP editor + mention button + serialize | button L871-876; deltaToHtml L1392,1494; htmlToJson L346 |
| Mobile | features/qontak_custom_form/.../toolbar/mention_toolbar_botton.dart | CDP mapper to change (Decision 8) | _mapMemberToMention L398-402 (href); picker L72-80 |
| Mobile | features/qontak_custom_form/.../member_cdp/member_cdp_response.dart | Confirms sso_id is available | fields id + sso_id L22-30 |
| Mobile | features/crm_note/.../note/note_screen.dart + note_item.dart | Render fallback (Decision 7) | stripHtml() L307/336; plain Text L51 |
| Mobile | features/crm_misc/.../notification_item_v2_mixin.dart + crm_core/.../qontak_app_route.dart | Notification routing (Decision 9) | external_url open L116-139; mapping L133-141 |
Existing Contracts to Reuse, Extend, or Replace (BE)
| Contract | Status | Justification | Owner |
|---|---|---|---|
POST /iag/v1/contacts/{contact_id}/notes | extended | Same endpoint; request HTML may carry mention anchors; response gains mentioned_user_ids + optional dropped_mentions | CDP Squad |
PUT /iag/v1/contacts/{contact_id}/notes/{contact_note_id} | extended | Adds mention parse + notified-set diff (re-mention/add allowed on edit — D-11); perm key customers_customernotes_manage unchanged | CDP Squad |
GET /iag/v1/contacts/{contact_id}/notes | reused | Returns stored anchors as-is; FE renders chip | CDP Squad |
| Company user list/search (typeahead) | reused / extended | FE 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) | reused | Verified 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) | extended | Add 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) | reused | Platform-owned; contract verified 2026-06-22 (route.go:57-58, notification_handler.go); CDP uses /crm not /chat | Notification/Platform |
MentionNotifyJob (gocraft/work) | new-with-justification | No async exists in notes path; reuse the IJobEnqueuer pattern for durable retry | CDP Squad |
bluemonday sanitizer policy | new-with-justification | No server-side HTML sanitizer exists; required by D-5 | CDP Squad |
Patterns to Follow
| Layer | Concern | Pattern in repo | Reference file | Deviation? |
|---|---|---|---|---|
| FE | State management | Pinia composition defineStore | store/UserStore.ts:179, store/CustomerStore.ts | none |
| FE | Error / toast | toastNotify wrapping pixel3 toast.notify; getApiErrorMessage reads resp_desc.en | utils/toast.ts, CustomerStore.ts:228-231 | none |
| FE | Sanitization | DOMPurify.sanitize(value) option-less (save + render) | NoteInput.vue:460, NotesList.vue:104 | yes — add {ADD_ATTR:['data-user-id','data-mention']} at BOTH sites |
| FE | HTTP | Nuxt $customFetch ($fetch.create) — not axios | common/composables/useCustomFetch.ts | none |
| FE | Hover card / popover (preview card — Decision 14) | MpPopover/MpPopoverTrigger/MpPopoverContent already used for the note action menu | NotesList.vue:31-46 | none (repurpose the same component for the mention preview card) |
| FE | Avatar | MpAvatar initials (Launchpad list/detail has no avatar) | NotesList.vue:20 | none (card avatar falls back to initials — OQ-22) |
| BE | HTTP handler shape | func(w,r)(myhttp.ResponseBody,error) wrapped by myHandler; decode→valid.Struct→service→NewJSONResponse | handler/contact_notes_handler.go:278, pkg/http/handler.go:66-85 | none |
| BE | Repository / DB | Mongo via repository.IDbRepo; bson.M filters; SetDefaults() | repository/contact_notes/*.go, repository/db.go:14-28 | none |
| BE | Async producer | IJobEnqueuer.EnqueueJob(ctx,jobName,params) (gocraft/work) | service/job_enqueuer.go:45-83 | none (net-new consumer for notes) |
| BE | Outbound HTTP | heimdall httpclient with timeout + log middleware | api/qontak_launchpad.go:38-48 | none (new client, same shape) |
| BE | Error response | BaseResponse{resp_code,resp_desc{id,en},meta}; ErrBadRequest()/ErrUnprocessableEntity()/... | pkg/http/{response,default_error}.go | none |
| BE | Logging / tracing | slog.*Context(ctx,...); OTel + Datadog | throughout notes svc/repo | none |
| Mobile | Mention insert | Quill LinkAttribute(href) via _mapMemberToMention | mention_toolbar_botton.dart:398-402 | yes — emit ssoId not id (Decision 8) |
| Mobile | Note render | stripHtml() + plain Text | note_screen.dart:307/336, note_item.dart:51 | none (fallback kept, Decision 7) |
| Cross | Naming (snake_case API ↔ camelCase FE / Dart) | API returns snake_case (sso_id,full_name); FE/Dart map to camelCase | UserStore.ts:11-19, member_cdp_response.dart | none |
Reading Order for the Agent
contact-service: internal/app/service/contact_notes/contact_notes_service.go— the write path to extend (create/update, 10k check).contact-service: internal/app/repository/contact_notes/base.go— the doc to addmentioned_user_idsto.contact-service: internal/app/handler/contact_notes_handler.go+internal/server/rest_router.go— handler shape, perms, company/owner extraction.contact-service: internal/app/api/qontak_launchpad.go— outbound client pattern (clone for the notification client).contact-service: internal/app/service/job_enqueuer.go+internal/app/handler/contact_sync_handler.go— the async enqueue pattern to reuse.qontak-customer-fe: features/customers/detail/components/Notes/components/NoteInput/NoteInput.vue— composer + save-path sanitize.qontak-customer-fe: .../NotesList/NotesList.vue+store/UserStore.ts— render sanitize + typeahead source; also theMpPopoverblock (NotesList.vue:31-46) to reuse for the preview card and wheregetUserDetail(ssoId)is added (Decision 14 / S05).mobile-qontak-crm: features/qontak_custom_form/.../mention_toolbar_botton.dart+.../member_cdp/member_cdp_response.dart— the mapper to change + proofsso_idexists.mobile-qontak-crm: features/crm_misc/.../notification_item_v2_mixin.dart+crm_core/.../qontak_app_route.dart— notification routing.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)
| Layer | Anchor / claim | Verified by | Evidence (1-line) |
|---|---|---|---|
| BE | 10,000-char limit | read | contact_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) |
| BE | ContactNote doc + no mention fields | read | base.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 |
| BE | Datastore = MongoDB (not SQL) | read/grep | go.mod go.mongodb.org/mongo-driver v1.12.1; migrations db/migrations/013_create_contact_notes.up.json (createIndexes) |
| BE | Routes + perms | read | rest_router.go:151-159 POST CustomersCustomerNotesAddKey, PUT CustomersCustomerNotesManageKey, GET ...ViewKey |
| BE | company/owner identity source | read | contact_notes_handler.go:470 owner from header X-Authenticated-Userid; company from ctx set in require_permission_middleware.go:97 (Launchpad CRS) |
| BE | No async in notes path | grep | `go func |
| BE | Async pattern exists | read | job_enqueuer.go:45-83 EnqueueJob; OTel inject L59-71; used contact_sync_handler.go:142,178 |
| BE | Outbound client pattern | read | qontak_launchpad.go:38 NewQontakLaunchpadClient(rootURL,auth,timeout) heimdall WithHTTPTimeout |
| BE | Error envelope | read | pkg/http/response.go:12-27 BaseResponse{ResponseCode,ResponseDesc{id,en},Meta} |
| BE | Test/build commands | read | Makefile test(L79-84), lint→staticcheck(L137-140), sec→gosec(L142-145), build(L45-48), migrate-up(L173) |
| FE | Vue3/Nuxt4 + pixel3 | read | package.json:42 vue ^3.5.13, :37 nuxt ^4.2.2, :24 @mekari/pixel3 1.0.10-dev.0 |
| FE | editorOptions fixed array | read | NoteInput.vue:155-166 RichTextEditorOption[][]; no mention/extension slot |
| FE | "Private" is static text | read | NoteInput.vue:39-44 literal Private text + info icon, no selector |
| FE | save-path sanitize (option-less) | read | NoteInput.vue:460 note: DOMPurify.sanitize(noteValue.value) |
| FE | render sanitize (option-less) + v-html | read | NotesList.vue:103-107 DOMPurify.sanitize(value); :49 v-html="sanitizeHtml(note.note)"; MpAvatar L20 |
| FE | note API + payload | read | CustomerStore.ts createNote POST /v1/contacts/notes/${contactId}; payload {note:string,attachments} |
| FE | typeahead source exists | read | UserStore.ts:83-129 getUsers() GET /v1/users?...statuses=active; User{id,email,full_name,status,sso_id} |
| FE | preview-card popover pattern exists | read | NotesList.vue:31-46 MpPopover/MpPopoverTrigger/MpPopoverContent/MpPopoverList used for the note action menu → reuse for the hover card; no dedicated user-card component today |
| FE | no per-user detail fetch today | read | UserStore.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) | read | qontak_launchpad.go:223-224 GetUserDetail → GET /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 |
| Mobile | preview card has no v1 target | read | mentions render plain text (note_screen.dart:307/336 stripHtml(), note_item.dart:51) — no chip to tap → mobile card deferred (Decision 7/14) |
| FE | no mention infra | grep | \bmention across .vue/.ts → 0 functional hits |
| FE | test/build | read | package.json test→vitest(L16), lint→eslint .(L18), build→nuxt build(L6); no e2e/typecheck script |
| Mobile | mention button (CDP) | read | detail_note_screen.dart:871-876 MpMentionToolbarButtonX(...,isCdp: widget.argument.isCdp) (PRD said 728-733) |
| Mobile | href insert + {id} type (OQ-10) | read | mention_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) |
| Mobile | serialize/deserialize | read | deltaToHtml L1392,1494; htmlToJson L346 (PRD said 1252/1348/329) |
| Mobile | no HTML renderer; stripHtml | read/grep | no flutter_html/markdown dep; note_screen.dart:307/336 .stripHtml(); note_item.dart:51 plain Text (PRD said 264) |
| Mobile | notif enums | read | notif_category.dart:30,56 mention='2'; notif_type_enum.dart:2-3 general(1),approval(2) |
| Mobile | routing | read | notification_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 |
| Mobile | gating flags | read | feature_flag_constant.dart:78-82 flag_one_notification default false; profile.dart:58 useQontakOneNotif=false; combined bottom_navigation_screen_mixin.dart:64-77 |
| Mobile | test/build | read | melos.yaml test→flutter test test/main_test.dart L97-99; analyze→flutter analyze L77-79; FVM Flutter 3.27.4 (.fvmrc) |
| NotifSvc | endpoints + auth + payload | read | notification-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) |
| NotifSvc | taxonomy + FCM fanout | read | notification_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 |
| Web | unified Notification Center + click | read | qontak-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 |
| Web | MFE embed | read | qontak-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 |
| Launchpad | user search + company UUID | read | qontak-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 |
| Mobile | app scoping | read | mobile-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) |
| Ext | CRM note.rb parity reference | — | UNVERIFIED — qontak.com repo not in workspace (conceptual precedent only) |
Design ↔ Code Mapping (frontend half)
| Figma frame / component | Implementing file | Reuse vs new | Tokens | Backing API | Deviation |
|---|---|---|---|---|---|
| Mention typeahead picker | NoteInput.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_ATTR | extended | TBD | GET …/notes | n/a — design pending |
| Mention preview card (web, S05) — Figma 15091-119388 | NotesList.vue chip + new preview-card component (reuse MpPopover L31-46) | new (reuse MpPopover) | pixel3 | getUserDetail(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-448947 | same component, inactive state | new | pixel3 | generic 404 (OQ-20) | never errors the note view |
| Mobile mention display | note_screen.dart / note_item.dart | reused (plain text) | n/a | GET …/notes | Decision 7 — plain-text fallback |
| Mobile preview card | n/a — deferred v1 | deferred | n/a | n/a | no 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_notesis a MongoDB collection; the diagram shows the document shape, not relational tables. The new fields arementioned_user_ids(current mentions, for rendering) andnotified_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-storedsso_idlazily 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_idsdeletes with the note (soft-delete unchanged). - Per-status lifecycle:
n/a — ContactNote has no status enum(onlyis_deletedsoft-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)
| Endpoint | Method | AuthN/AuthZ | Request schema | Response schema | Status codes | Idempotency | Versioning | Reuse? |
|---|---|---|---|---|---|---|---|---|
/iag/v1/contacts/{contact_id}/notes | POST | chi 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), 500 | none (create) | path /iag/v1 | extended |
/iag/v1/contacts/{contact_id}/notes/{contact_note_id} | PUT | + customers_customernotes_manage; owner-only (verified service:196) | same as POST | ContactNoteResponse + mentioned_user_ids + dropped_mentions? | 200, 400, 401, 403, 404, 422, 500 | last-write-wins (no version field today) | /iag/v1 | extended |
/iag/v1/contacts/{contact_id}/notes | GET | + customers_customernotes_view | query: pagination | list incl. stored note HTML + mentioned_user_ids | 200, 401, 403, 500 | n/a | /iag/v1 | reused |
| Company user list (typeahead) | GET | session auth | ?statuses=active&page&per_page (+ query= if/when Launchpad adds it — OQ-6) | [{ id, sso_id, full_name, avatar?, status }] | 200, 5xx | n/a | /v1 (verified FE call) | reused / extended |
| Single-user detail (preview card — S05; FE-direct, lazy on hover) | GET | session auth | get_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), 5xx | n/a | Launchpad 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}/notesand 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}onCUSTOMER_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)
| Call | Method | Auth | Request (per recipient) | Timeout | Retry | Failure behavior |
|---|---|---|---|---|---|---|
POST /api/v1/notifications/crm | POST | X-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? } | 10s | 3× 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, authflexible_auth_middleware.go:14-40). Corrections vs the prior draft: endpoint is/crm(the/chatendpoint requiresnotif_type=3);titleis required and was missing; there is noskip_fcmfield — FCM (mobile) fan-out is automatic forcrm-origin (service.go:47-49), so the same single call delivers to both the web center andmobile-qontak-crm;organization_idis optional*uuid.UUIDmetadata, not the recipient (OQ-8 resolved); there is no top-levelextra.originfield on the request —extrais a generic map andoriginis determined by which endpoint (/crm) is called. Mobile tap-through still uses theexternal_urlrouting path (Decision 9) on the consuming side. The only open product choice (OQ-1, narrowed): reuse the genericmention(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'] })atNoteInput.vue:460(save) andNotesList.vue:104(render). Without this, mention identity is stripped silently (verified default behavior). - State ownership: Pinia
CustomerStore(note CRUD) +UserStore(typeahead source); localnoteValueref inNoteInput.vue. - Analytics events:
cdp_note_mention_added(create or edit with ≥1 mention; carriesmention_counttotal +new_mention_count+event),cdp_note_mention_picker_failed(typeahead error). - A11y: typeahead listbox
role="listbox"/option, arrow-key navigation,aria-activedescendant; mention chiprole="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 emittingsso_id(Decision 8). - Anchor emitted:
<a href="../../../users/{sso_uuid}/edit_user">@{full_name}</a>. - Render: plain-text
@Namefallback (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 theNotesList.vue:31-46pattern) + a new preview-card component. - Trigger: hover (desktop) on the chip; the chip's
data-user-idsupplies thesso_id. - Data:
UserStore.getUserDetail(ssoId)→{ full_name, email, staff_level, status }(Launchpadget_by_sso_id); teams resolved viagetTeams()mapping or omitted (OQ-22); avatar =MpAvatarinitials. Client-cached bysso_idfor 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_viewuser). - Analytics (optional, FE-local):
cdp_note_mention_preview_failedon lookup error (mirrorscdp_note_mention_picker_failed). - A11y: card is keyboard-focusable from the chip (
role="link"); card content readable by SR;Esccloses.
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 PiniaUserStore.getUsers(). - Cache key: in-store
usersref (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)→ Launchpadget_by_sso_id, fired lazily on hover (not on list load), result cached bysso_idfor 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
| Surface | Loading | Empty | Error | Partial | Success |
|---|---|---|---|---|---|
| Web typeahead | spinner in dropdown | "No matching people." | "Couldn't load people — try again" (note still savable) | n/a | users listed w/ avatar |
| Web mention chip (render) | n/a | n/a (no mentions → plain note) | dropped mention → warn chip (Decision 5) | mix of chips + text | styled @Name chip |
| Mobile picker | list skeleton | "No matching people." | "People list unavailable — check your connection" | n/a | member list |
| Mobile note render | n/a | n/a | never raw HTML (stripHtml) | n/a | plain @Name text |
| Web preview card (S05) | skeleton/spinner while resolving | n/a (card only opens on a chip) | inactive/not-found (404, Figma 15091-448947) or "Couldn't load details" (5xx) — note stays readable | teams row omitted if unresolved (OQ-22) | card: name, email, staff level, team(s) |
| Mobile preview card | n/a — deferred v1 | n/a | n/a | n/a | n/a (Decision 7/14) |
Detail 2.D — Data Integrity Matrix
| Write path | Transaction scope | Partial failure | Idempotency | Consistency | Duplicate-event handling | Stale-read |
|---|---|---|---|---|---|---|
| Create note + mentions | single Mongo doc insert (atomic) | if insert fails → 5xx, nothing persisted, no enqueue | none (create); dedupe mentions within the note (Set) | strong (single doc) | n/a (create) | n/a |
| Update note + mentions | single Mongo doc $set (atomic; sets mentioned_user_ids + appends to notified_user_ids) | if update fails → 5xx, no enqueue, set not appended | last-write-wins | strong (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 job | best-effort after commit | enqueue fails → log, note saved | per-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) | eventual | dedupe key prevents double-send on job retry and across concurrent PUTs | n/a |
Detail 2.E — Concurrency Collision Map
| Resource | Writers | Collision | Resolution | On conflict |
|---|---|---|---|---|
| Same note doc | two agents (web + mobile) editing concurrently | both 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 job | worker retries | same job retried | idempotency 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
| Job | Trigger | Input shape | Retry | DLQ | Concurrency | Idempotency key | Timeout | Poison handling |
|---|---|---|---|---|---|---|---|---|
MentionNotifyJob | enqueued 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 call | after 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 / service | Inbound trigger | Outbound effect | Failure handler | PRD anchor |
|---|---|---|---|---|---|
| 1. Compose + insert anchor | CDP FE / Mobile | agent picks user | HTML with anchor (web data-user-id / mobile href sso_id) | picker error → savable w/o mention | S01, S02 |
| 2. Parse + validate + sanitize + persist | CDP contact-service | POST/PUT …/notes | note + mentioned_user_ids stored | drop-and-warn / 422 | S01/AC-3, S04 |
| 3. Enqueue notify per not-yet-notified mention | CDP contact-service | note committed (create/edit) | MentionNotifyJob on Redis; append to notified_user_ids | enqueue fail → log, note saved | S03/AC-1 |
| 4. Dispatch notification | CDP worker → Notification/Platform | job consumed | POST /api/v1/notifications/crm | retry×3 → log failed | S03/ERR-1 |
| 5. Render + deliver | Notification/Platform | service receives | web center (qontak-unified in hub/hub-chat) + FCM (mobile-qontak-crm), auto for crm origin | platform-owned | S03/AC-2 |
| 6. Tap-through | Mobile Squad (route) / FE (web) | recipient taps | web: notes tab; mobile: external_url opens web URL | unmapped 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 (reusemention(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
| Entity | State field / event | Default | Updated by | Read via | Stale window |
|---|---|---|---|---|---|
| ContactNote | mentioned_user_ids (current mentions) | [] (absent on old docs) | CreateNote/UpdateNote | GET …/notes | none (read-after-write same doc) |
| ContactNote | notified_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) |
| Notification | delivery (sent/failed) | none | MentionNotifyJob | unified Notification Center (qontak-unified in hub/hub-chat) + mobile One Notif V2 | async; visible via metrics/logs only to author |
Detail 2.G — Cross-Layer Contract Verification
| Endpoint | BE response schema | FE/Mobile expected schema | Match? | 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_mentions | partial | FE/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/avatar | yes | null-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) | partial | response 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_ATTR | partial | DOMPurify 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) | partial | until 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 HTML | web NotificationCenter (qontak-unified-component) + mobile One Notif V2 render these fields | yes (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 tab | partial (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 toGET …/notes.
Detail 2.I — Scope Boundaries
- BE create:
mention/parser.go,mention/policy.go(bluemonday),MentionNotifyJobhandler + registration,api/notification_client.go. BE modify:repository/contact_notes/base.go(+mentioned_user_ids+notified_user_idsfields),service/contact_notes/contact_notes_service.go(create/update + notified-set diff),payload/contact_notes_request.go/response,api/qontak_launchpad.goor a new validate method, worker bootstrap. BE NOT touched: attachments, delete, ownership, permission keys, soft-delete. - FE create: mention picker component (or
MpAutocompletereuse), 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 +MpPopoverwrap for the card),CustomerStore.ts(read new response fields), maybeUserStorefilter. 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.dartCDP mapper (sso_id), CDP note response model (readmentioned_user_idsif needed), notification payload handling already routes viaexternal_url. Mobile NOT touched: Quill codec, CRM (non-CDP) mapper, notification routing core. - Shared modules:
mention_toolbar_botton.dartis 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)
| Asset | Type | Source | Format & sizes | Path |
|---|---|---|---|---|
| Mention chip styling | CSS (no new asset) | design-system tokens (pixel3) | n/a | NotesList.vue scoped styles |
| Mention/typeahead icon | icon | pixel3 (reuse) | SVG | from @mekari/pixel3 |
No net-new image/lottie/font assets. Mobile reuses the existing
MpIcons.interfaceEssential.textEditorMention(verifieddetail_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 innpm run buildsize 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):
| Event | Trigger | Properties |
|---|---|---|
cdp_note_mention_added | note 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, event — mention_count = total mentions on the note; new_mention_count = newly-notified this save (` |
cdp_note_mention_notify_sent | notification dispatched per mention | note_id, recipient_sso_id, notification_id |
cdp_note_mention_notify_failed | dispatch failed after retries | note_id, recipient_sso_id, reason, retry_count |
cdp_note_mention_invalid | mention SSO UUID failed company validation | note_id, company_sso_id, invalid_sso_id, action |
cdp_note_mention_picker_failed | typeahead/user-list call failed | company_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_failedrate > 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 inmeta.trace_id(verified envelope). - PII: do not log note content or full names; log
sso_ids (pseudonymous) and ids only.notebody 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):
bluemondayallow-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_idfrom the authenticated context, verifiedrequire_permission_middleware.go:97); cross-company ids are dropped + logged (NEG-1). - Input validation:
mentioned_user_idsmust 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_idto company-scoped directory data (name/email/staff level/team(s)) for a viewer who already hascustomers_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 (thesso_idcomes from the note's own sanitized anchor). Company scope is enforced by the same session auth as the typeahead; a cross-companysso_idresolves to a generic 404 (inactive/not-found), never leaking another tenant's directory. - SSRF:
click_action_urlis server-constructed from a trusted host template, not user input. - Secrets:
notification-serviceX-Api-Keyvia the existing config/Vault mechanism (hashicorp/vault/apipresent); never logged. TheNotificationClient(§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 theX-Api-Keyor body. - Static analysis:
make sec→gosec(verifiedMakefile:142-145). - Compliance: see §3.D.
Role × Endpoint Authorization Matrix
| Role | Endpoint(s) | Methods | Tenant scope | UI visibility | Constraint | Audit |
|---|---|---|---|---|---|---|
| Agent (author) | …/notes | POST (add), PUT (manage) | own company | @ affordance when flag ON | ≤10 mentions; owner-only update (verified service:196) | note write + cdp_note_mention_added |
| Viewer | …/notes | GET (view) | own company | chip visible, no @ | read-only | none |
| Mentioned recipient | (external notification stream) | n/a | own | sees notification | cannot retract (D-6) | cdp_note_mention_notify_sent |
| System/service | internal | validate + dispatch | enforces company scope | n/a | server authoritative | cdp_note_mention_invalid |
Detail 3.A — Failure Mode Catalog (merged)
| Surface | FE behavior on failure | BE response on failure | Code-shape consistency |
|---|---|---|---|
| Typeahead source down | "Couldn't load people — try again"; savable w/o mention | n/a (FE→user source) | yes |
| Note too long / >10 mentions | inline validation error | 422 NOTE_TOO_LONG / TOO_MANY_MENTIONS | yes (FE reads resp_desc.en) |
| Invalid/cross-company mention | warn chip on dropped id | 200 + dropped_mentions[] (Decision 5) | yes |
| Notification Service 5xx/timeout | silent to author (note saved) | async retry×3 → log cdp_note_mention_notify_failed | yes |
| Crafted anchor (XSS) | n/a | sanitized server-side; only allow-listed markup persists | yes |
Detail 3.A.1 — Branch & Skip Catalog
| Branch trigger | Where checked | Downstream effect | Audit | User-visible? |
|---|---|---|---|---|
cdp_notes_mention_enabled OFF | FE composer + BE service entry | no parse/validate/dispatch; note behaves as today (NEG-2) | none | no (no @ affordance) |
| Recipient == author | BE service (before notified-set check) | exclude from notify (S03/ERR-2) | none | no |
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) | none | no |
| Invalid SSO UUID | BE service validation | drop id; note saves (Decision 5) | cdp_note_mention_invalid | yes (warn chip) |
| Private note + mention (OQ-9) | n/a — no server-side privacy model exists (verified base.go/read.go) | notify normally (no branch) | none | no — 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 }.
| Endpoint | Error code | HTTP | Message (en) | When | User-facing? |
|---|---|---|---|---|---|
POST/PUT …/notes | NOTE_TOO_LONG | 422 | "Note content exceeds maximum length of 10,000 characters" (verified string) | total HTML > 10000 | yes |
POST/PUT …/notes | TOO_MANY_MENTIONS | 422 | "A note can mention at most 10 people" | > 10 mentions | yes |
POST/PUT …/notes | BAD_REQUEST | 400 | validation failure | malformed body | yes |
PUT …/notes/{id} | FORBIDDEN | 403 | "access denied: only note owner can update" (verified) | non-owner update | yes |
…/notes/{id} | NOT_FOUND | 404 | "note not found or access denied" (verified) | wrong contact/company | yes |
Detail 3.C — Error Message Catalog (FE)
| Error | User message (i18n key TBD) | Surface | User-facing? |
|---|---|---|---|
| picker load failed | "Couldn't load people — try again" | inline dropdown | yes |
| 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 state | yes |
| mobile offline picker | "People list unavailable — check your connection" | bottom sheet | yes |
| dropped mention | "{name} couldn't be mentioned" warn chip | inline | yes |
| note too long | "Note is too long" | inline | yes |
| preview card — inactive/not-found (S05/ERR-1, generic 404) | inactive-avatar + last-known @Name (Figma 15091-448947); no error toast | preview card | yes |
| preview card — lookup failed (S05/ERR-2, 5xx/timeout) | "Couldn't load details" (name only); note stays readable | preview card | yes |
Detail 3.D — Compliance & Data Governance
| Field | Classification | Legal basis | Retention | Encryption | Access audit | Right-to-delete |
|---|---|---|---|---|---|---|
note (HTML) | user-generated content (may contain PII) | UU PDP / legitimate interest | same as note (unchanged) | at rest (Mongo) + TLS in transit | note read perms | deletes with the note (soft-delete unchanged) |
mentioned_user_ids | pseudonymous user identifier (PII-linked) | UU PDP | same as note | at rest + transit | as above | deletes with the note |
| notification record | delivery metadata | UU PDP | platform-owned (Notification Service) | platform-owned | platform-owned | not recallable once sent (D-6) — documented limitation |
notification body (title/description) | contact name + author name egress to notification-service | UU PDP | platform-owned (permanent record) | platform-owned | platform-owned | PII-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 (
notemay now carry mention anchors — existing clients sending plain notes are unaffected). Response addsmentioned_user_ids(+ optionaldropped_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 existingflag_one_notificationfor 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_idsare harmless if left. Per-layer rollback in §4.E. - PIC + timeline: CDP Squad; per-stage TBD.
Detail 4.A — Cross-Layer Rollout Compatibility Matrix
| Scenario | FE | BE | Works? | Mitigation |
|---|---|---|---|---|
| Pre-deploy | Old | Old | yes | baseline |
| Backend first | Old | New (flag OFF/dark) | yes | BE ignores anchors when none sent; flag OFF → no dispatch |
| Frontend first | New | Old | no | FE would emit anchors the old BE stores unsanitized + never parses → avoid: deploy BE first (chosen order) |
| Both deployed | New | New | yes | target state |
| Backend rollback | New | Old | no | roll back FE first, or flag OFF; new BE field is additive so data is safe |
| Frontend rollback | Old | New | yes | BE still parses any stored anchors; chips degrade to plain <a> (DOMPurify strips data-* on old FE) |
Detail 4.B — Configuration Contract
| Layer | Env var / flag | Type | Default | Required | Provisioner | Secret? |
|---|---|---|---|---|---|---|
| BE+FE+Mobile | cdp_notes_mention_enabled | bool (per-company flag) | OFF | yes | Ops (flag service) | no |
| BE | NOTIFICATION_SERVICE_URL | string | — | yes | config/Vault | no |
| BE | NOTIFICATION_SERVICE_API_KEY | string | — | yes | Vault | yes |
| BE | NOTIFICATION_SERVICE_TIMEOUT | duration | 10s | no | config | no |
| BE | CDP_NOTES_MENTION_MAX | int | 10 | no | config (Decision 6) | no |
| Mobile | flag_one_notification | bool | OFF (verified) | yes (notif half) | flag service | no |
Detail 4.C — Test Plan (commands sourced from each repo)
| Layer | Command (source) | What it proves |
|---|---|---|
| BE unit | make test → go 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 lint | make lint → staticcheck ./... (Makefile:137-140) | static analysis clean |
| BE security | make sec → gosec (Makefile:142-145) | no new security findings on the write path |
| BE build | make build (Makefile:45-48) | compiles with new deps (bluemonday) |
| BE migration | make migrate-up (Makefile:173) | optional mention index applies (if added) |
| FE unit | npm run test → vitest (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 test → vitest | getUserDetail 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 lint | npm run lint → eslint . (package.json:18) | lint clean |
| FE build | npm run build → nuxt build (package.json:6) | builds (MFE) |
| Mobile unit | melos run test → flutter 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 analyze | melos run analyze → flutter analyze (melos.yaml:77-79) | analyzer clean |
| Cross-layer | manual/integration: web POST note w/ data-user-id and mobile POST w/ href → both yield identical mentioned_user_ids | D-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
| Order | Layer | Chunk | Files to modify/create | Commands | Acceptance criteria |
|---|---|---|---|---|---|
| 1 | BE | Add mentioned_user_ids + append-only notified_user_ids fields + optional index | repository/contact_notes/base.go; db/migrations/0NN_contact_notes_mention_index.up.json/.down.json | make build, make migrate-up | both fields compile; round-trip bson; absent on old docs → empty; index applies + rolls back |
| 2 | BE | mention.Parser (dual-form D-9) + max-10 + UUID validation | internal/app/service/mention/parser.go (+ _test.go) | make test | unit: web data-user-id form + mobile href sso_id form both → same []sso_id; malformed dropped; >10 → error |
| 3 | BE | Server-side sanitizer policy (Decision 3 concrete spec) | internal/app/service/mention/policy.go; go.mod (+bluemonday) | make test, make sec | golden test: allow-listed formatting + mention anchor survive; <script>, onerror=, javascript:/data: href, style=, and extra attrs all stripped (Decision 3 spec) |
| 4a | BE | Add 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 test | unit: given sso_ids + company, returns only active company members; non-members/inactive excluded |
| 4b | BE | Wire 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 test | create 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 |
| 5 | BE | NotificationClient (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 site | make test, make build | request 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 |
| 6 | FE | DOMPurify ADD_ATTR at save + render; read new response fields | NoteInput.vue:460; NotesList.vue:104; CustomerStore.ts | npm run test, npm run lint | vitest: data-user-id/data-mention survive sanitize; dropped-mention warn chip shows |
| 7 | FE | Mention typeahead + chip (after OQ-7 spike + Figma) | new MentionPicker (or MpAutocomplete); NoteInput.vue; chip styles NotesList.vue | npm run test, npm run build | typeahead lists company users (sso_id, null-safe name); selecting inserts anchor; chip renders |
| 8 | Mobile | CDP mapper emits sso_id; render fallback verified | mention_toolbar_botton.dart (_mapMemberToMention l.398-402) | melos run test, melos run analyze | Dart test: emitted href = .../users/{ssoId}/edit_user; list shows @Name (no raw HTML) |
| 9 | Mobile | Confirm 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 analyze | mention notification with origin=external_url opens click_action_url; no "cannot redirect" toast; mobile-qontak-chat not targeted |
| 10 | FE | Web deep-link to notes tab (OQ-17) | qontak-customer-fe: parse ?tab=notes in CustomerActivityV2.vue; un-gate the Notes tab from isStaging | npm run test, npm run build | navigating to /customers/{id}?tab=notes (from the unified-center click) auto-selects the Notes tab |
| 11 | FE | UserStore.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 lint | vitest: 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) |
| 12 | FE | Mention preview card on hover (S05) — after chunk 7 (chip) + Figma 15091-119388/448947 | new 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 build | vitest: 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 keepsdata-user-idso the card can read thesso_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 lint2)make sec3)make test4)make build - FE: 1)
npm run lint2)npm run test3)npm run build - Mobile: 1)
melos run analyze2)melos run test
- BE: 1)
- Post-deploy signals:
cdp_note_mention_notify_sent / (sent+failed) ≥ 99%(CDP dashboard).cdp_note_mention_picker_failedrate < 10%.- No new
gosec/ stored-XSS findings; note-write P95 ≤ 2s (Datadogcontact-servicedashboard).
- Rollback (deploy-order-aware):
- Toggle
cdp_notes_mention_enabledOFF for affected companies (instant; stops parse/validate/dispatch + hides@). - If FE is the problem: revert the FE PR (BE field is additive — safe).
- If BE is the problem: roll back FE first, then the BE PR; the additive field is harmless if left.
- Confirm
cdp_note_mention_notify_failedreturns to baseline and note-write P95 ≤ 2s in the next 15 min.
- Toggle
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.
| # | Type | Question | Status after verification / mitigation | Owner |
|---|---|---|---|---|
| OQ-1 | Narrowed (verification) | notif_type/notif_category/event_type for a CDP note-mention; web Notification Center parity | VERIFIED 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-2 | Open | Company-wide vs team-scoped candidates | Default company-wide (Decision 11). Team-scoping deferred. | PM |
| OQ-3 | Resolved (Decision 5) | Invalid mention → reject vs drop | Drop-and-warn adopted. | PM + Eng |
| OQ-4 | Resolved (Decision 6) | Max mentions per note | 10 adopted (config CDP_NOTES_MENTION_MAX). | PM + Eng |
| OQ-5 | Resolved (Decision 9) | Mobile deep-link into a CDP note | external_url + web URL for v1; native route deferred. Confirm host/URL w/ Mobile before beta. | Notification + Mobile |
| OQ-6 | Resolved (verification) | Server ?query= search vs reuse existing getUsers | VERIFIED 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-7 | Resolved-with-fallback | Does 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-8 | Resolved (verification) | company_sso_id (string) → Notification organization_id (UUID) mapping | VERIFIED 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-9 | Resolved (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-10 | Resolved (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-11 | Resolved (Decision 7) | Mobile chip vs plain-text | Plain-text fallback for v1 (no HTML renderer — verified). | PM + Mobile |
| OQ-12 | Resolved (Decision 8) | Mobile codec change vs backend dual-parse | Both: backend dual-parses (D-9) and mobile mapper emits sso_id (no Quill codec change needed). | CDP Eng + Mobile |
| OQ-13 | Resolved (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-14 | Open (REV-4) — sole remaining blocker | Web 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-15 | Resolved (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-16 | Resolved (re-pinned for PRD v1.5) | cdp_note_mention_added count semantics + FE i18n keys | PRD v1.5 splits the counts: mention_count = total mentions on the note; new_mention_count = newly-notified this save (` | to_notify |
| OQ-17 | Open (REV-13) — internal, small | Web notification deep-link into the CDP notes tab | The 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-18 | Resolved (verification) | Which mobile app receives CDP mention notifications | mobile-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-19 | Open (REV-14) — infosec ack | Auto-FCM exposes the contact name on a mobile lock-screen push | notification-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-20 | Resolved (← 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-21 | Resolved (← PRD OQ-14, v1.5) — by Decision 4 | Remove→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-22 | Open (PRD v1.6 CHG-004) — non-blocking | The 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 reviewrfc-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 (narrowed —GetCompanyDetaillacks 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_idoptional;company_sso_idis 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
| Date | Comment(s) From | Action Item(s) |
|---|---|---|
| 2026-06-18 | RFC 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-18 | rfc-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-18 | RFC 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-18 | rfc-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-18 | RFC 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-22 | RFC 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 dissolved — organization_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-22 | rfc-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-30 | RFC 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.GetUserDetail → LaunchpadUserDetailResponse (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 getUserDetail → MpPopover 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-26 | RFC 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_idsset 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-cachedgetUserDetail→MpPopover; mobile deferred). Minimum coverage addressed (storage, sync/async, sanitize/third-party, consistency, multi-tenancy, reuse/new; per-statusn/ajustified). - ✅ §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):
- 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", recipientsso_id,X-Api-Key; web center already rendersmention. Only a non-blocking product choice remains (reuse vs add a CDP category). - OQ-8 (company UUID). Dissolved —
organization_idis optional metadata andcompany_sso_idis itself a UUID; no mapping needed. - OQ-6 (user search). Launchpad
GET /private/users?query=&statuses[]=activeverified to exist. - OQ-18 (mobile app).
mobile-qontak-crmonly;mobile-qontak-chatexcluded (Decision 13).
New scoped internal gap (not blocking BE/mobile):
- OQ-17 — web deep-link to the notes tab. Two small
qontak-customer-fechanges (parse?tab=notes; un-gate the Notes tab). No change toqontak-unified-componentor 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.GetActiveUsersBySsoIdsspecified; §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-reviewercycle 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.