Skip to Content

← All archived runs

Run: csm-outreach-thread

run.md

Run: csm-outreach-thread

  • branch: claude/csm-outreach-thread-pipeline-kcl5ox
  • pr: #515

00_intake/stub.md

Stub: CSM dashboard — outreach / communication thread

  • feature-slug: csm-outreach-thread
  • epic: csm-dashboard-data
  • personas: CSM ↔ blocking actor (Customer | Expert | Vendor | Partner)
  • initiative: Build the Bridge / objective: Q2 2026 Objective 3 — Validate Technical Infrastructure & Payout Flow
  • depends-on: csm-portfolio-foundation, csm-blocker-queue
  • sequence: 5 of 5

Problem

The CommunicationSection shows, per blocked account, the CSM's conversation with the blocking party — the latest message (lastMessage), whether they've replied (responseStatus: pending | responded) and when last contacted (lastContact) — all dummy (apps/web/lib/mock/csm.ts, the lastContact / responseStatus / lastMessage fields on each blocker). There is no record of CSM outreach: nothing stores that the CSM chased the owner, what was said, or whether a reply came back.

Proposed change

  • Extend customer-dashboard-data/project-messaging (the per-lead thread model) to carry CSM outreach messages tied to a blocker/account, rather than introducing a second thread model. Capture sender (CSM), text, createdAt, and the addressed actor.
  • Derive responseStatus (pending until the blocking actor replies after the CSM's last message) and lastContact (time of the CSM's most recent outreach), and surface the lastMessage preview per account.
  • Wire the CommunicationSection: per selected account, show the latest outreach + status, and let the CSM post a new outreach message to the blocking party.

Acceptance criteria (rough)

  • Each blocked account shows the real latest outreach message, correct responseStatus, and real lastContact — no mock.
  • The CSM can post an outreach message against an account/blocker; it persists on the shared thread.
  • responseStatus flips to responded when the blocking actor replies after the CSM's last message.
  • Outreach reuses the project-messaging thread model — no parallel message collection.

Out of scope (this feature)

  • A full chat surface (attachments, reactions, read receipts) beyond the latest-message + post-outreach the dashboard needs — the messaging epic owns that.
  • New realtime infrastructure — rides on project-messaging's existing Ably scope if present, else server-fetch on load.
  • Routing/automating outreach (templated chases, reminders) — manual outreach only this round.

Notes for Define

  • Coordinate with customer-dashboard-data/project-messaging: that stub is itself flagged as potentially epic-sized. If it has landed, extend its model with the actor/blocker association; if not, agree the minimal thread shape together so this doesn't fork it.
  • Decide how an outreach message associates to a blocker vs the account thread (a blockerId tag vs a filtered view of the account thread).
  • touches: packages/services/src/db/models (extend message/thread model), packages/services/src/server (outreach post + responseStatus derivation), apps/web/components/dashboard/csm/communication-section.tsx.

01_define/output/spec.md

Spec: CSM dashboard — outreach / communication thread

  • slug: csm-outreach-thread
  • personas: CSM, Customer
  • touches: packages/services/src/db/models/project-message.ts, packages/services/src/db/services/project-message, packages/services/src/db/services/csm-blocker-queue, apps/web/app/(app)/csm/dashboard/page.tsx, apps/web/components/dashboard/csm/csm-sections.tsx, apps/web/components/dashboard/csm/communication-section.tsx
  • complexity: standard

Problem

The CSM is the exception manager for delivery — they push the 5–10% of accounts stuck before go-live through to value, which is exactly what Build the Bridge → Q2 2026 Objective 3 (Validate Technical Infrastructure & Payout Flow) depends on. The blockers table and action strip are now real (csm-blocker-queue, PR #511), but the communication section of the Go live control dashboard is still dummy: CsmSections passes the mock blockers array straight into CommunicationSection, so the latest message, whether the blocking party has replied (responseStatus) and when they were last contacted (lastContact) are all fabricated from apps/web/lib/mock/csm.ts. Nothing records that the CSM chased the owner, what was said, or whether a reply came back — so against a now-real blocker queue the CSM still can't see who has gone silent or what to chase next, leaving the exception-management loop the objective rests on half-wired.

Proposed change

Give each go-live blocker a real, persisted outreach conversation by extending the already-landed project-messaging thread (ProjectMessage, the per-lead customer↔team thread) — no parallel message collection — and pairing it to the real blocker rows csm-blocker-queue (#511) now produces:

  • Tag outreach to the blocker it addresses. Add an optional blocker reference to ProjectMessage so a message can be scoped to a specific go-live blocker, while still belonging to the account's single lead thread. Messages with no blocker remain the account-level thread.
  • CSM posts outreach as team; the customer replies as customer — reusing the existing senderRole semantics. This round covers the customer blocking actor only (see Out of scope).
  • Derive the dashboard signals per blocker (and per account): lastMessage (latest message preview), lastContact (timestamp of the CSM's most recent outreach), and responseStatuspending after the CSM's most recent outreach until the customer replies, then responded.
  • Read + post over the real portfolio. Surface the derived outreach state for the same blocker set the go-live queue shows, joining on the leadId csm-blocker-queue added to the CsmPortfolioAccount envelope (and the blocker id on each CsmBlockerQueueRow). The read can extend the csm-blocker-queue service envelope or sit beside it in @sustentus/services/server; a post action appends a CSM outreach message to the shared thread. Both reuse the page's existing mine/tenant scope.
  • Wire the communication section to this real data. On the server-rendered /csm/dashboard, fetch outreach alongside csmBlockerQueueService and pass it through CsmSections to CommunicationSection, replacing the mock blockers array it receives today.

Dependency — csm-blocker-queue (PR #511). That feature supplies the real blocker rows this section pairs outreach to and the leadId-bearing portfolio envelope the join needs. It is built and ticked Ready to merge, but not yet squash-merged into main, so its code is not on this run's branch yet. Build of this run rebases onto #511 once it merges — a sequencing step, not an unresolved requirement. Every decision about what gets built is settled below.

Acceptance criteria

  • Each blocker/account card in the communication section shows the real latest outreach message, the correct responseStatus, and a real lastContactCommunicationSection no longer receives the mock blockers array, and no value comes from lib/mock/csm.
  • A CSM can post an outreach message against a selected blocker/account; it persists on the shared project-messaging thread (tagged with the blocker it addresses, no parallel message collection) and appears immediately as the latest message.
  • responseStatus reads pending after the CSM's most recent outreach and flips to responded once the customer replies after it.
  • The outreach cards pair to the same real blocker set the go-live queue renders — keyed via the leadId on the portfolio envelope and the blocker id on each CsmBlockerQueueRow, not by a mock account name.
  • Outreach read + post are tenant- and portfolio-scoped, reusing the page's existing mine/tenant scope — a CSM never sees another tenant's thread, nor an unowned account's when scope is mine.
  • The communication section renders from server-fetched outreach data on the live /csm/dashboard (fetched alongside csmBlockerQueueService), not from a mock array.

Out of scope

  • Outreach to non-customer blocking actors (expert / vendor / partner) — this round reuses the customer↔team thread only; addressing other actors would require modelling threads project-messaging does not have today.
  • A full chat surface (attachments, reactions, read receipts) beyond latest-message + post — the messaging epic owns that.
  • New realtime infrastructure — rides on project-messaging's existing scope (Ably if present), else server-fetch on load.
  • Routing or automating outreach (templated chases, reminders) — manual outreach only this round.
  • Re-modelling the blocker queue, SLA, finance or health layers — owned by their own features; this run consumes csm-blocker-queue's read, it does not change it beyond the small envelope reuse above.
  • Replicating any change back into apps/dashboards — that app stays the dummy source-of-truth.

Open questions

  • none. The only sequencing item is the csm-blocker-queue (#511) merge noted above — it is built and Ready to merge; Build rebases onto it. No requirement is left open.

02_build/output/notes.md

Build notes: csm-outreach-thread

  • commits: feat: csm-outreach-thread — persisted CSM outreach per go-live blocker

Built on the merged csm-blocker-queue (#511): the branch was rebased onto main (which now carries csmBlockerQueueService, the leadId portfolio-envelope field, and the renamed CsmSections) before implementing.

What changed

  • packages/services/src/db/models/project-message.ts: added an optional blocker ref to ProjectMessage (+ a { tenantId, blocker, createdAt } index) so a message can be tagged to the go-live blocker it addresses while staying on the account's single lead thread — no parallel message collection.
  • packages/services/src/db/services/project-message/index.ts: added the CSM-side outreach surface — getOutreachForBlockers (one tenant-scoped read of blocker-tagged messages → per-blocker OutreachState: latest preview, the CSM's last-contact time, and responseStatus pendingresponded once the customer replies after the CSM's last outreach) and postOutreachForCsm (appends a team message tagged with the blocker on the lead's thread, notifies the customer via the existing publish path).
  • packages/services/src/db/services/csm-blocker-queue/index.ts: added leadId to CsmBlockerQueueRow so per-account features join cleanly without re-resolving the portfolio.
  • packages/services/src/db/services/csm-outreach/{index,instance}.ts: new CsmOutreachService.getOutreachForCsm — composes the blocker queue (the canonical blocker set + account/stage/who/status) with the per-blocker outreach derivation, so the communication cards pair exactly to the go-live queue's blockers. Exported via the services barrel → @sustentus/services/server.
  • apps/web/app/(app)/csm/dashboard/page.tsx: fetches outreach alongside the blocker queue and passes it (and the post action) into CsmSections.
  • apps/web/app/(app)/csm/dashboard/actions.ts: added postOutreachAction — locates the blocker in the CSM's tenant queue (tenant boundary), then posts via postOutreachForCsm.
  • apps/web/components/dashboard/csm/csm-sections.tsx: drops the mock blockers import; passes real outreachRows + postOutreach to the communication section.
  • apps/web/components/dashboard/csm/communication-section.tsx: rewired to real outreach rows; each card shows the real latest message, response status and last-contact, and adds an inline compose box to post outreach (optimistically updated in place on success).

Acceptance criteria status

  • Real latest message / responseStatus / lastContact per blocker — CommunicationSection no longer receives the mock blockers array; data is from csmOutreachService.
  • CSM can post outreach against a selected blocker; persists on the shared thread (tagged with the blocker, no parallel collection) and appears immediately as the latest message.
  • responseStatus reads pending after the CSM's last outreach, flips to responded once the customer replies after it (deriveOutreach).
  • Cards pair to the same blocker set the go-live queue renders — keyed via leadId (portfolio envelope) + the blocker id on CsmBlockerQueueRow.
  • Read + post are tenant- and portfolio-scoped, reusing the page's mine/tenant scope; post resolves the blocker within the CSM's tenant queue.
  • Renders from server-fetched outreach on the live /csm/dashboard, fetched alongside csmBlockerQueueService.

Verify result

  • mechanical checks (format · lint · typecheck · build) run in CI + the Vercel preview, not here. No check is expected to fail.

Notes for review

  • Outreach reuses the existing ProjectMessage (customer↔team) thread; CSM posts as team. Non-customer blocking actors (expert/vendor/partner) are out of scope this round.
  • postOutreachAction recomputes the CSM's tenant blocker queue to resolve leadId and enforce the tenant boundary — correct and reuses existing logic; a lighter lookup could replace it later if the manual-post path ever gets hot.

03_release/output/changelog.md

Changelog: csm-outreach-thread

  • title: Chase blocked accounts without leaving your dashboard
  • personas: csm
  • entry: apps/help/app/changelog/2026-06-23-csm-outreach-thread/page.mdx

As a CSM, you can now message the customer holding up a go-live — right from the blocker on your Go live control dashboard. Each conversation shows the latest message, whether the customer has replied since you last reached out, and when you last made contact, so you can see at a glance who has gone quiet and who to chase next.

03_release/output/investor-update.md

CSMs can now chase blocked accounts from one screen

Who it's for: Customer success managers What shipped: A CSM can message the customer holding up a go-live blocker and see, per blocker, the latest reply, whether the customer has responded, and when they were last contacted. Why it matters: Faster human follow-up on stuck accounts advances Build the Bridge — Q2's objective to validate technical infrastructure and the payout flow.

Dig deeper: https://github.com/sustentus/sustentus/pull/515 · https://help.sustentus.com/changelog/2026-06-23-csm-outreach-thread

03_release/output/release.md

Release: csm-outreach-thread

  • pr: #515 · merged: yes — 2026-06-23 (squash 7b0d614)
  • CI: green (Format · Lint · Typecheck · Audit · preview-migrate all success; prod-migrate skipped)
  • technical docs: no technical docs impact — services/models are not enumerated at this granularity in apps/docs/app/technical/**
  • business docs: updated in this PR — feature-role-matrix/projects (CSM can send blocker outreach to the customer) and service-journey/delivery (CSM outreach extension to the project message thread + optional blocker field)
  • release notes: both — changelog entry (apps/help/app/changelog/2026-06-23-csm-outreach-thread/) + investor draft in this PR
  • deploy: READY — production web (platform.sustentus.com) + help (help.sustentus.com) both green on 7b0d614
  • sent: investor update sent to 2 recipients on 2026-06-23, after green deploy

Review summary

  • /code-review (high effort) over the feature diff — no blocking correctness bugs. Tenant/portfolio scoping is enforced (outreach read keyed off the CSM's scoped queue; post resolves the blocker in the CSM's tenant queue and the lead is tenant-scoped). Soft-delete + tenant filters match the sibling customer message methods.
  • Minor (accepted): relativeContact in communication-section.tsx derives "Xm ago" from Date.now() at render, so a card crossing a minute boundary could differ between SSR and hydration — cosmetic and self-correcting on hydration; not worth added state complexity.

Acceptance check (vs spec)

  • Real latest message / responseStatus / lastContact per blocker — CommunicationSection takes server outreach rows; mock blockers feed removed.
  • CSM can post outreach against a blocker — persists on the shared thread tagged with the blocker (postOutreachForCsm, senderRole team); card updates in place.
  • responseStatus pending until the customer replies after the CSM's last outreach (deriveOutreach).
  • Cards pair to the real blocker set via leadId (portfolio envelope) + blocker id on CsmBlockerQueueRow.
  • Tenant- and portfolio-scoped (mine/tenant) read + post.
  • Renders from server-fetched outreach on /csm/dashboard, fetched alongside csmBlockerQueueService.