Skip to Content

← All archived runs

Run: csm-portfolio-foundation

run.md

Run: csm-portfolio-foundation

  • branch: claude/charming-tesla-nwvun3
  • pr: #512

00_intake/stub.md

Stub: CSM dashboard — portfolio foundation + account ownership

  • feature-slug: csm-portfolio-foundation
  • epic: csm-dashboard-data
  • personas: CSM
  • initiative: Build the Bridge / objective: Q2 2026 Objective 3 — Validate Technical Infrastructure & Payout Flow
  • depends-on: none
  • sequence: 1 of 5

Problem

The CSM "Go live control" dashboard (apps/web/app/(app)/csm/dashboard/page.tsx) is a "use client" page reading entirely from apps/web/lib/mock/csm.ts, with a hard-coded MY_CSM = "Sarah Chen". Two things block any section from being real: (1) the page is not scoped to the signed-in CSM, and (2) there is no way for a CSM to own an account — the lead model has manager, expert, vendor, customer but no csm reference, so "My accounts" and every per-CSM rollup have nothing to group by. Region grouping exists on the account (lead.location.region) but is unused.

Proposed change

  • Add a csm reference to the lead model (a user whose role is csm), so an active-project lead can be owned by a CSM; index it for per-CSM queries. Surface the existing location.region.
  • Convert /csm (and /csm/dashboard) to a server-fetched page scoped to the signed-in CSM, with the "My accounts" vs all toggle resolving to a real query (lead.csm = me vs the wider tenant set) instead of the client-side MY_CSM string filter.
  • Wire the header (the real signed-in CSM identity) and stand up the shared read-service envelope in @sustentus/services/server that the activation rollup, blocker queue and outreach sections all read from (one portfolio query → the CSM's active-project leads).

Acceptance criteria (rough)

  • A lead can be assigned a csm owner (user, role csm); the field is queryable/indexed per tenant.
  • /csm is server-rendered and scoped to the signed-in CSM; "My accounts" shows only their owned accounts, the toggle widens to the tenant set.
  • The header shows the real signed-in CSM, not the hard-coded name.
  • A single portfolio read-service returns the CSM's active-project leads (with region) for the other features to extend — no section still imports lib/mock/csm.

Out of scope (this feature)

  • The activation rollup, blocker queue, snapshot and communication sections (features 2–5) — this only stands up the scoping + ownership the rest read.
  • A UI to assign CSMs to accounts (assignment may be seeded/back-office this round); the dashboard reads the assignment, it does not manage it.

Notes for Define

  • Decide assignment semantics: is lead.csm set explicitly, or defaulted from region/territory? At minimum the field + index must exist; seeding can be manual.
  • Confirm the "all accounts" side of the toggle is tenant-scoped (a CSM seeing peers' accounts) vs. team-scoped — the mock shows all four CSMs, implying tenant-wide read.
  • touches: packages/services/src/db/models/lead.ts (add csm ref + index), packages/services/src/server (portfolio read-service), apps/web/app/(app)/csm/{page.tsx,dashboard/page.tsx} (server-fetch + scope).

01_define/output/spec.md

Spec: CSM dashboard — portfolio foundation + account ownership

  • slug: csm-portfolio-foundation
  • personas: CSM
  • touches: packages/services/src/db/models/lead.ts, packages/services/src/db/services/csm-portfolio/, packages/services/src/db/services/index.ts, apps/web/app/(app)/csm/page.tsx, apps/web/app/(app)/csm/dashboard/page.tsx, apps/web/lib/mock/csm.ts
  • complexity: standard

Problem

The web CSM "Go live control" dashboard (apps/web/app/(app)/csm/dashboard/page.tsx) is a "use client" page that reads entirely from apps/web/lib/mock/csm.ts, with a hard-coded MY_CSM = "Sarah Chen" and a client-side filter on that name. Two things block any section from being real: (1) the page is not scoped to the signed-in CSM, and (2) there is no way for a CSM to own an account — the lead model has manager, expert, vendor, customer references but no csm, so "My accounts" and every per-CSM rollup have nothing to group by. This is sequence 1 of 5 in the csm-dashboard-data epic and the prerequisite for the other four sections (activation rollup, daily snapshot, blocker queue, outreach). It advances Build the Bridge / Q2 2026 Objective 3 — Validate Technical Infrastructure & Payout Flow by proving the CSM dashboard can resolve real, tenant- and CSM-scoped portfolio data end to end.

Proposed change

  • Add a csm reference to the lead model — a user whose role is csm (ref: "user", mirroring the existing manager field), with an index { csm: 1, status: 1 } to match the other ownership indexes, so an active-project lead can be owned by a CSM and queried per tenant. Assignment is seeded / back-office this round (no UI) — the dashboard reads the assignment, it does not manage it. The account region already exists on lead.location.region; the portfolio service surfaces it (no new field).
  • Convert /csm and /csm/dashboard to a server component that resolves the Clerk-authenticated CSM → their tenant (getTenant) → the CSM app user (resolveAppUserForTenant, role === "csm"), reusing the same helpers the customer dashboard already uses. The page header renders the real signed-in CSM identity, not the hard-coded MY_CSM string.
  • Stand up the shared portfolio read-service csmPortfolioService in @sustentus/services/server (new db/services/csm-portfolio/ following the established index.ts class + instance.ts singleton pattern, exported through db/services/index.ts). It returns one typed portfolio envelope — the single fetch entry every other section in the epic extends rather than re-querying leads:
    • header: the CSM's display name + initials.
    • accounts[]: the CSM's active-project leads (lead.isActiveProject = true) → { id (requestId), account (customer company / lead title), region, status, customerId }.
    • It takes a scope: "mine" filters lead.csm = <signed-in CSM>; "tenant" returns the whole tenant's active-project set. Both are always tenant-isolated.
  • Wire the "My accounts" vs "all" toggle to the real query. The toggle drives the scope the service resolves (mine vs tenant) so the rendered account set is real and the toggle has an observable effect (mine → the CSM's owned accounts; all → the tenant-wide set). The client-side MY_CSM string filter and the myAccountsOnly name filter are removed.
  • Retire the mock incrementally. Foundation wires the header and the portfolio account set / scope to real data and removes those fields from apps/web/lib/mock/csm.ts. The activation-rollup metrics, the blocker queue + action strip, and the communication section stay on the mock this round — each is wired to real data by its own feature (2, 4, 5). Render a loading state while the portfolio resolves and a defined empty state ("no accounts assigned yet") when the CSM owns no accounts — no crash, no mock fallback for the header/portfolio.

Acceptance criteria

  • A lead can be assigned a csm owner (a user with role csm); the field is indexed and queryable per tenant ({ csm: 1, status: 1 }).
  • /csm (and /csm/dashboard) is server-rendered and scoped to the signed-in CSM; the header shows the real signed-in CSM, not the hard-coded Sarah Chen.
  • csmPortfolioService returns a single typed portfolio envelope (header + accounts[] with region) from @sustentus/services/server, scoped by tenant: scope: "mine" returns only leads where lead.csm is the signed-in CSM, scope: "tenant" widens to the tenant's active-project set — a CSM can never see another tenant's accounts.
  • The "My accounts" vs "all" toggle resolves to the real query: "My accounts" shows only the CSM's owned accounts and the toggle widens to the tenant set, with an observable difference in the account set (not a client-side string filter).
  • A CSM who owns no accounts sees the defined empty state, not a crash and not the mock portfolio.
  • The header fields are removed from apps/web/lib/mock/csm.ts; the still-mocked sections (activation table, blockers, communication) continue to render unchanged pending their own features.

Out of scope

  • The activation rollup metrics (RAG counts, go-lives-next-7-days, at-risk-now, changeVsYesterday, the narrative summary) — feature 2 (csm-activation-rollup); the activation table stays mock-driven this round.
  • The daily portfolio snapshot that makes changeVsYesterday a real delta — feature 3 (csm-portfolio-snapshot).
  • The blocker queue + action strip (portfolio-wide blocker rows, value-at-risk, SLA-breach sort, resolve action) — feature 4 (csm-blocker-queue); stays mock this round.
  • The communication / CSM outreach section — feature 5 (csm-outreach-thread); stays mock this round.
  • A UI to assign CSMs to accounts — assignment is seeded / back-office this round; the dashboard reads it, it does not manage it.
  • Backfilling / migrating csm onto existing leads.
  • Realtime / websocket changes — the page is server-fetched on load.
  • Cross-CSM management / team-lead views beyond the "My accounts" vs "all" toggle.

Open questions

  • none

02_build/output/notes.md

Build notes: csm-portfolio-foundation

  • commits: feat: csm-portfolio-foundation — lead.csm ownership + portfolio read-service, feat: csm-portfolio-foundation — server-render /csm scoped to the signed-in CSM

What changed

  • packages/services/src/db/models/lead.ts: added a csm reference (ref: "user", mirroring manager) to both ILeadPopulated (PopulatedUser) and ILead (ObjectId, added to the Omit), the schema field, and the index { csm: 1, status: 1 } (alongside the existing manager/expert/vendor ownership indexes). Region is unchanged — it already lives on lead.location.region.
  • packages/services/src/db/services/csm-portfolio/{index.ts,instance.ts}: new csmPortfolioService with getPortfolioForCsm(tenantId, csmUserId, scope) returning the typed CsmPortfolioEnvelope (header = the CSM's name/initials; accounts[] = active-project leads → { id, account, region, status, customerId }). scope: "mine" filters lead.csm = &lt;csm>; scope: "tenant" widens to the tenant's active-project set. Both filters always carry tenantId, so a CSM can never read another tenant.
  • packages/services/src/db/services/index.ts: export the new service instance + its types (barrelled to @sustentus/services/server via db/index.ts).
  • apps/web/app/(app)/csm/dashboard/page.tsx: rewritten from a "use client" mock page into a server component. Resolves the tenant (getTenant) + signed-in CSM (resolveAppUserForTenant, role === "csm"), reads ?scope=all (→ tenant, default mine), fetches the envelope, and renders the real header (signed-in CSM name) + the PortfolioPanel. Returns CsmDashboardEmpty when there is no tenant in session or the user is not a CSM.
  • apps/web/components/dashboard/csm/portfolio-panel.tsx: new server component — the real accounts table (account / region / status / project id) plus the "My accounts" vs "all" scope toggle (next/link to /csm and /csm?scope=all). Renders an inline empty state when the scoped set is empty.
  • apps/web/components/dashboard/csm/csm-mock-sections.tsx: the previous client page body (action strip, activation rollup table, go-live blockers, communication) extracted verbatim into a client island so its shared useState (filters / resolved / selected) keeps working unchanged. The hard-coded identity is gone from the page; a local MOCK_CSM constant still drives the mock activation-table filter until the csm-activation-rollup feature wires it to real data.
  • apps/web/components/dashboard/csm/empty-state.tsx: new CsmDashboardEmpty for the no-tenant / non-CSM guard case.

Acceptance criteria status

  • A lead can be assigned a csm owner (user, role csm); indexed/queryable per tenant — lead.csm + { csm: 1, status: 1 }.
  • /csm (and /csm/dashboard) is server-rendered and scoped to the signed-in CSM; the header shows the real signed-in CSM, not Sarah Chen.
  • csmPortfolioService returns a single typed envelope (header + accounts[] with region) from @sustentus/services/server, tenant-scoped, mine vs tenant.
  • The "My accounts" vs "all" toggle resolves to the real query (searchParam → service scope), with an observable difference in the account set — not a client-side string filter.
  • A CSM who owns no accounts sees the defined empty state (inline in the portfolio panel), not a crash and not the mock portfolio.
  • The hard-coded header identity is removed; the still-mocked sections (activation table, blockers, communication) render unchanged. Note: apps/web/lib/mock/csm.ts held no header fields to remove — the hard-coded MY_CSM lived in the page, so the equivalent removal happened there. The mock data arrays still feed the deferred sections and are untouched.

Verify result

  • mechanical checks (format · lint · typecheck · build) run in CI + the Vercel preview, not here. Release reads them back from the PR's check runs.

Notes for review

  • The scope toggle is a searchParam (?scope=all) re-fetch, server-side — it links to the canonical /csm path (not /csm/dashboard), so toggling from the alias route normalises the URL to /csm.
  • MOCK_CSM = "Sarah Chen" in csm-mock-sections.tsx is intentionally retained — it only filters mock data and disappears when csm-activation-rollup / csm-blocker-queue replace those sections.
  • Assignment of lead.csm is seeded / back-office this round (no UI) per the spec; the dashboard only reads it.

03_release/output/changelog.md

CSM dashboard — see your own portfolio of active accounts

CSMs can now sign in to a go live dashboard that is scoped to them: it shows the real accounts they own (with region and status) and a toggle between "My accounts" and the wider team's active set, replacing the shared placeholder data.

03_release/output/investor-update.md

CSMs now manage their own real portfolio of active accounts

Who it's for: Customer success managers (CSM) What shipped: The CSM go live dashboard is now scoped to the signed-in CSM and lists their real owned accounts, with a toggle between their portfolio and the whole team's. Why it matters: First real, per-CSM account ownership — groundwork for Build the Bridge, Q2 2026 Objective 3 (validate technical infrastructure & payout flow).

Accounts can now be owned by a CSM and queried per tenant, the foundation every CSM rollup builds on.

Dig deeper: <merged-PR URL> · <changelog entry URL>

03_release/output/release.md

Release: csm-portfolio-foundation

  • pr: #512 · merged: pending (Ready to merge ticked; awaiting CI green)
  • CI: green on the pre-release build commit; re-checking after release commit
  • technical docs: no technical docs impact — the lead.csm field and the internal csmPortfolioService are not described at field/service granularity in apps/docs/app/technical/**; no app, route, env var, or architecture change.
  • business docs: no business docs impact — the feature-role-matrix/leads permissions are unchanged (CSMs already view/own leads); CSM-to-account assignment is back-office with no UI this round, so the source-of-truth pages don't lag.
  • release notes: both — changelog entry (apps/help/app/changelog/2026-06-22-csm-portfolio-foundation/) + investor draft in this PR
  • deploy: pending (verify web + help READY post-merge)
  • sent: pending (after green deploy)

Review summary

  • Un-awaited service call in resolvePortfolio (apps/web/app/(app)/csm/dashboard/page.tsx) — a rejection inside getPortfolioForCsm escaped the try/catch and would throw out of the component instead of degrading to the empty state. Fixed on branch (return await).
  • Duplicated fullName/initialsFor helpers in csm-portfolio/index.ts — accepted: matches the existing per-service pattern (customer-project, sdm, expert-workbench); not worth a new shared abstraction in a foundation slice.
  • header.initials not yet read by the page — accepted: it's the spec'd envelope shape (header: name + initials) for downstream rollup features to consume, not dead code.
  • Scope toggle links to /csm (not /csm/dashboard) — accepted: both routes render the same server component; the URL normalises to the canonical path.

Acceptance check (vs spec)

  • Lead can be assigned a csm owner; indexed/queryable per tenant — lead.csm + { csm: 1, status: 1 }.
  • /csm server-rendered + scoped to the signed-in CSM; header shows the real CSM.
  • csmPortfolioService returns the typed envelope from @sustentus/services/server, tenant-scoped mine/tenant.
  • "My accounts" vs "all" toggle resolves to a real query (searchParam → service scope).
  • CSM with no accounts sees the defined empty state.
  • Hard-coded identity removed; still-mocked sections render unchanged.