Skip to Content

← All archived runs

Run: customer-surface-fixes

run.md

Run: customer-surface-fixes

  • branch: claude/customer-surface-fixes-9xb0ku
  • pr: #814

02_define/output/spec.md

Spec: Fix the two customer surfaces broken by product code

  • slug: customer-surface-fixes
  • personas: Customer
  • touches: apps/web/app/(app)/customer, packages/services/src/db/services/customer-project
  • complexity: standard

Problem

Two customer-persona surfaces are wrong in product code, so no amount of seeding can make them right. The customer dashboard's "What could go wrong" card renders a hardcoded fixture (apps/web/app/(app)/customer/dashboard/_customer-fixture.ts, flagged P0 in the file's own header) — every customer sees the same two invented blockers ("Waiting on API key", "Connector module delayed") regardless of their project, while the risk badge beside them is computed from real data. And /customer/satisfaction's "Awaiting your feedback" strip filters leads on statusNames: ["csat"] (page.tsx:37), a status that does not exist in the workflow engine (db/workflows/workflows.json — the post-delivery status is survey_sent), so no engine-legal lead can ever light it and the customer is never told a survey is waiting.

Both are audit findings — _source/audit-report.md Phase 3, "Surfaces seed data cannot light" items 1–2 — and decision D3 of _source/decisions.md put them in scope. They sit on the Refine the bridge initiative, objective Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners: the demo tenant is the vendor-facing proof, and a dashboard that shows the same fictional blockers to everyone, plus a CSAT loop that never opens, undercuts it directly.

Proposed change

Blockers card. Carry the customer's real open blockers through the existing envelope. CustomerProjectService.getActiveProjectForCustomer already fetches them via blockerService.listOpenByLead and feeds them to computeProjectHealth; the envelope just doesn't expose them. Add a blockers field to CustomerProjectEnvelope (label / owner / impact — exactly the blocker model's fields, which are also the shape the card already renders), populate it from the blockers already read, and have the dashboard pass project.blockers to RiskBlockersFeedback instead of the fixture. No extra query.

The blocker model's owner spans five parties, and two of them — Partner and Vendor — are raised on the expert workbench and the CSM blocker queue in wording written for internal readers. The envelope therefore carries only the three customer-facing owners (Customer, Your team, Expert). Health is deliberately not filtered the same way: every open blocker keeps feeding computeProjectHealth, so withholding the wording never understates the risk the customer is shown. Delete _customer-fixture.ts and its import — that is the file's only remaining consumer. The card's existing empty state ("No blockers — project on track") becomes reachable and is the honest answer when there are none.

Satisfaction strip. Point the "Awaiting your feedback" filter at the engine's post-delivery status: statusNames: ["survey_sent"]. Submitting the survey already progresses the lead survey_sent → completed (submitCustomerCsatActionleadService.progressStatusPositive), so a completed survey drops off the strip on the next load with no further change. Drop the dead "csat" entry from that action's ELIGIBLE_CSAT_LEAD_STATUSES guard in the same pass — it names the same nonexistent status and can only mislead the next reader.

No other persona surface changes, and no seeding changes: blocker documents are already written by the demo storyline, and this run makes the customer dashboard show whatever is there.

Acceptance criteria

  • apps/web/app/(app)/customer/dashboard/_customer-fixture.ts no longer exists and nothing in apps/web imports it.
  • The customer dashboard's "What could go wrong" card lists exactly the open (status: "open") customer-facing blockers on the signed-in customer's active-project lead — owner Customer, Your team or Expert — each with its real label, owner and impact, and renders the "No blockers — project on track" state when the lead has none.
  • Blockers owned by Partner or Vendor never appear on the card; they still feed computeProjectHealth, so the risk badge continues to account for them.
  • CustomerProjectEnvelope exposes the active project's open blockers, populated from the read that already feeds computeProjectHealth — the dashboard adds no new database round-trip.
  • A lead in survey_sent belonging to the signed-in customer appears in the "Awaiting your feedback" strip on /customer/satisfaction; a lead in any other status does not.
  • Submitting that lead's CSAT removes it from the strip (the lead progresses to completed).
  • No status string "csat" remains as a lead-status filter or guard under apps/web/app/(app)/customer/satisfaction/.
  • Unit coverage for the envelope's blocker mapping (label/owner/impact carried through, empty list when there are no open blockers), following the Testing conventions in CONVENTIONS.md.
  • No change to admin, CSM, SDM, expert or vendor surfaces.

Out of scope

  • Any seeding or demo-storyline change — storyline-full-coverage-generator and saas-vendor-storyline own the data.
  • Redesign of either surface. This is data wiring: the blockers card, its empty state and the satisfaction strip keep their current markup and copy.
  • De-duplicating the strip against already-submitted CSATs. The status transition is the mechanism, duplicate submission is blocked by the CSAT unique index, and the survey page already redirects when a response exists — so the only residue is a stale strip row if progressStatusPositive fails (it is caught and logged today). Adding a bulk CSAT-by-leads lookup to remove that row is a larger change than this fix warrants.
  • Resolving/creating blockers from the customer dashboard — the card stays read-only.
  • The blocker model, its indexes, and blockerService — read as-is.

Open questions

  • none

Context budget: within the Inputs table. Read the stub, _source/breakdown.md + _source/decisions.md (this scope has no scope.md — the breakdown records D1–D11 as its equivalent), the knowledge map, _shared/github.md, and the four code sites the stub cites file:line, plus blocker.ts and leads/index.ts to confirm the field shape and the filter path.

03_build/output/notes.md

Build notes: customer-surface-fixes

  • commits: feat: customer-surface-fixes — real blockers on the customer risk card, feat: customer-surface-fixes — satisfaction strip off the engine's survey stage, chore: customer-surface-fixes — build notes

What changed

  • packages/services/src/db/services/customer-project/index.ts: added CustomerProjectBlocker and a blockers field on CustomerProjectEnvelope, populated by a new exported blockersFrom mapper. The rows come from the blockerService.listOpenByLead call that already ran to feed computeProjectHealth — the same array now feeds both, so the risk card and the risk badge cannot disagree, and the dashboard makes no extra query. toEnvelope's openBlockers parameter widened from { label: string }[] to Pick<IBlocker, "label" | "owner" | "impact">[]; health still reads only label.
  • apps/web/app/(app)/customer/dashboard/page.tsx: dropped the _customer-fixture import and passed project.blockers to RiskBlockersFeedback. The component's existing "No blockers — project on track" branch is now reachable and is the empty state; its markup and copy are untouched (spec: data wiring, not UI work).
  • apps/web/app/(app)/customer/dashboard/_customer-fixture.ts: deleted — the dashboard was its only consumer.
  • apps/web/app/(app)/customer/satisfaction/page.tsx: statusNames: ["csat"]["survey_sent"], with a comment recording why that status is the right one and how a row leaves the strip.
  • apps/web/app/(app)/customer/satisfaction/[leadId]/actions.ts: dropped the dead "csat" entry from ELIGIBLE_CSAT_LEAD_STATUSES; the guard now names only the engine status.
  • packages/services/src/db/services/customer-project/index.test.ts: new — unit coverage for blockersFrom, written from the acceptance criteria.
  • apps/web/AGENTS.md: removed the parenthetical that documented the quarantined fixture as a flagged P0 exception — the file it pointed at no longer exists.

Acceptance criteria status

  • _customer-fixture.ts no longer exists and nothing in apps/web imports it — file deleted via git rm; grep -rn "_customer-fixture\|customerData" apps/web/ returns nothing.
  • The card lists exactly the open customer-facing blockers on the customer's active-project lead with real label/owner/impact, and shows the empty state when there are none — listOpenByLead already filters status: "open" and scopes by tenant + lead; blockersFrom keeps only owners Customer/Your team/Expert; RiskBlockersFeedback branches on blockers.length === 0.
  • Partner/Vendor blockers never reach the card but still feed health — blockersFrom filters, computeProjectHealth still receives the unfiltered openBlockers array. Added at Verify from a code-review finding (commit owner-filter).
  • CustomerProjectEnvelope exposes the open blockers from the read that already feeds computeProjectHealth, with no new round-trip — blockersFrom(openBlockers) maps the in-memory array that was already fetched; no new service call.
  • A survey_sent lead for the signed-in customer appears in the strip; other statuses do not — findAll filters status: { $in: ["survey_sent"] } alongside the existing customerId and tenantId scoping.
  • Submitting the CSAT removes it from the strip — unchanged existing behaviour: submitCustomerCsatAction calls progressStatusPositive, which moves the lead survey_sent → completed, so it no longer matches the filter.
  • No "csat" status string remains under customer/satisfaction/ — verified by grep.
  • Unit coverage for the blocker mapping — four cases in index.test.ts: fields carried through, empty list, order and count preserved, and internal fields (tenantId, lead, status, escalated, escalationCount) dropped rather than leaked.
  • No change to admin, CSM, SDM, expert or vendor surfaces — the diff touches only customer/dashboard, customer/satisfaction, and the customer-project envelope. blockerService itself is unchanged, so the CSM blocker queue and expert workbench readers are untouched.

Notes for Verify

  • Where to look hardest: blockersFrom is a new export on a module that has no barrel line, so it is public to the package but not to @sustentus/services/server. It is exported so the unit tier can reach it — getActiveProjectForCustomer needs Mongo and there is no integration tier yet. If you would rather it stayed module-private, the alternative is dropping the test.
  • Preview smoke needs data: the demo tenant must have blocker documents with status: "open" on the customer's active-project lead for the card to show rows, and a lead in survey_sent for the strip. Both are the storyline seeder's job (storyline-full-coverage-generator, saas-vendor-storyline), not this run — an empty card and an absent strip on the preview are correct behaviour here, not a regression. Check the empty state renders cleanly either way.
  • Deliberately not done (spec → Out of scope): the strip is not de-duplicated against already-submitted CSATs. If progressStatusPositive throws it is caught and logged, leaving the lead in survey_sent and a stale row on the strip; clicking it lands on the survey page, which redirects back because a CSAT exists. Duplicate submission is blocked by the CSAT unique index and by that redirect, so the residue is cosmetic.
  • owner narrows to BlockerOwner in the envelope but RiskBlockersFeedback still types its prop as string. Assignable, so no error; tightening the component prop would be UI work this spec excludes.

Context budget: within the Inputs table — the spec, run.md, CONVENTIONS.md, apps/web/AGENTS.md, packages/services/AGENTS.md, the knowledge map, _shared/github.md, and the files named in touches: plus blocker.ts, leads/index.ts and vitest.shared.ts to confirm the field shape, the filter path and the test include pattern.

04_verify/output/verify.md

Verify: customer-surface-fixes

  • production-readiness: not required — no migration, no model/schema/index change, no new process.env read (so nothing owed to turbo.jsonglobalEnv), no payments, no route-policy or Clerk change. The diff's only DB contact is a read-path filter (statusNames) and an in-memory projection of rows that were already being fetched.
  • code-review: medium (spec complexity: standard); the CI Claude review is off.github/workflows/claude-code-review.yaml is gated on vars.ENABLE_CLAUDE_REVIEW == 'true' and its check run reported skipped — so the review was run here. 2 findings: 1 fixed on branch, 1 accepted (already recorded as spec Out of scope).
  • security-review: run — no HIGH/MEDIUM findings at reportable confidence. One defence-in-depth observation recorded below.
  • playwright: TODO — manual DoD smoke performed instead (E2E tier not built; contract §5 note).

DoD smoke (on the preview — each line says who verified it)

Preview: https://web-git-claude-customer-surface-fixes-9xb0ku-sustentus.vercel.app

  • _customer-fixture.ts gone, nothing imports it — git rm'd; grep -rn "_customer-fixture\|customerData" apps/web/ returns nothing (agent)
  • CustomerProjectEnvelope exposes the blockers with no new round-trip — traced in the diff: blockersFrom(openBlockers) maps the array listOpenByLead had already returned for computeProjectHealth; no added service call (agent)
  • Partner/Vendor blockers withheld from the card, still counted by health — traced: blockersFrom filters, computeProjectHealth still receives the unfiltered array; four unit cases assert the split (agent)
  • No "csat" lead-status filter or guard under customer/satisfaction/ — grep returns nothing (agent)
  • Strip query is tenant- and customer-scoped — traced: tenantId from getTenantOrNull(), customerId from resolveAppUserForTenant behind a role !== "customer" redirect; neither derives from searchParams (agent)
  • Unit coverage present and green — Quality Project success on dbd1a26, the head carrying the owner filter: format, lint, typecheck (incl. the satisfies readonly BlockerOwner[] clause) and all seven blockersFrom cases (agent, read from CI)
  • Preview builds and loads — Vercel web deployment Ready on the branch (agent)
  • Blockers card renders the customer's real open blockers — signed-in, needs a customer account on the preview (operator — NOT YET PERFORMED)
  • Card shows "No blockers — project on track" when the lead has none (operator — NOT YET PERFORMED)
  • survey_sent lead appears in "Awaiting your feedback"; other statuses do not (operator — NOT YET PERFORMED)
  • Submitting the CSAT removes the row (operator — NOT YET PERFORMED)
  • auth: customer persona signs in and reaches the dashboard (operator — NOT YET PERFORMED)
  • payments: not touched — no payment path in the diff (agent)
  • notifications: none expected — the diff adds no notifyX call and changes no notification trigger; submitCustomerCsatAction's existing behaviour is unchanged (agent)

The five unticked lines are the signed-in half. The agent has no preview credentials, so they are recorded as not performed rather than assumed. The quality gate should not be confirmed until they are reported — see "Open at hand-off".

Findings & cleanup

  1. Internally-owned blockers rendered on the customer's card (code-review) — fixed on branch, 0765dfa. blockersFrom passed all five BlockerOwner values through, so Partner and Vendor rows — raised on the expert workbench and the CSM blocker queue, worded for internal readers — would have been repeated verbatim to the customer. Now filtered to Customer / Your team / Expert, with the allow-list written as const satisfies readonly BlockerOwner[] so renaming an owner breaks the build rather than silently hiding a row the customer should see. This narrowed an already-approved acceptance criterion, so spec.md was updated in the same commit and the PR body's criteria text reconciled from it (file → PR, one direction). Flagged here because the Spec-approved tick predates the change.

    • Deliberate consequence: health is not filtered the same way — every open blocker still feeds computeProjectHealth. The risk badge can therefore read Medium/High while the visible list is shorter than that implies, or empty. Chosen over the alternative (filtering health too), which would let an internal blocker silently understate the customer's risk. Note the badge could already outrun the list before this run — overdue milestones and overdue invoices raise severity with no blocker at all — but the fixture always rendered two rows, so the combination has never actually been seen on screen. Worth a look during the operator pass.
  2. Stale row if progressStatusPositive fails (code-review) — accepted, no change. Already recorded in spec.md → Out of scope at Define: the transition is the mechanism, duplicate submission is blocked by the CSAT unique index, and the survey page redirects when a response exists, so the residue is a cosmetic row that bounces back. Removing it needs a bulk CSAT-by-leads lookup this run does not warrant.

  3. findAll's customer filter is fail-open (security-review, defence-in-depth — not a vulnerability, no change) — leadService.findAll applies filter.customer only if (customerId && isValid(customerId)); an absent or malformed id silently widens the query to tenant-wide. Not exploitable: the caller passes resolved.user._id.toString(), always a valid ObjectId, and there is no attacker-reachable path to an invalid value. Recorded because this run makes the code path reachable for the first time — statusNames: ["csat"] previously matched zero rows unconditionally, masking the shape. Worth hardening if findAll ever gains a caller whose customerId comes from less trustworthy input.

  4. The dead csat status survives elsewhere (observation, out of scope) — apps/web/components/projects/projects-list/projects-list.tsx lists "csat" in both PROJECT_STATUS_NAMES and ALL_LEAD_STATUS_NAMES, alongside other non-engine spellings (pre-qualification, analysis, requirements_analysis, awaiting_customer_feedback, processing, work-in-progress, closed_lost). Deliberately permissive legacy superset, untouched by this run — the spec scoped the cleanup to customer/satisfaction/. Candidate for a chore lane: reconcile that list against the workflow engine. (apps/web/lib/workspace/views.ts's "csat" is a workspace section id, not a lead status — correctly left alone.)

Open at hand-off

  • CI re-run on the owner-filter commitdone: green. All checks on dbd1a26 settled with no failures: Quality Project, Migrate preview database, Audit database, and the four pipeline advisories. Migrate production database and Review diff against CONVENTIONS.md skipped (not applicable pre-merge / ENABLE_CLAUDE_REVIEW off).
  • The five signed-in DoD lines above are unperformed. Blocker/survey_sent data may be absent on the preview — the storyline seeder that lights them is storyline-full-coverage-generator / saas-vendor-storyline, later in this batch. An empty card and an absent strip are correct behaviour here; the empty state rendering cleanly is the thing to confirm.
  • No walkthrough clip posted to #build — the agent cannot record one, and the surfaces are unlit on the preview until the storyline stubs land.

Context budget: within the Inputs table — spec, notes, run.md, the branch diff against origin/main, _shared/github.md, plus blocker.ts, leads/index.ts, risk-blockers-feedback.tsx and claude-code-review.yaml to settle the two findings and the review-enablement question.

05_ship/output/changelog.md


title: Real blockers on your dashboard, and a nudge when feedback is due date: 2026-08-17T12:00:00Z personas: [customer] slug: customer-surface-fixes pr: https://github.com/sustentus/sustentus/pull/814

Real blockers on your dashboard, and a nudge when feedback is due

Two things on your project pages now show you your own project.

  • What could go wrong lists the blockers actually open on your project — what each one is, who owns clearing it, and what it costs you. Until now this panel showed the same two example items to everyone, whatever their project. If nothing is blocking delivery, the card says so.
  • The list covers the parties you deal with: you, your delivery team, and your expert. Notes your team raises against a partner or supplier stay internal — but they still count towards your risk rating, so the badge beside the list can read higher than the list alone explains.
  • Satisfaction now tells you when a survey is waiting. Once your project is delivered and the survey goes out, it appears under Awaiting your feedback until you fill it in. Previously nothing ever appeared there, so a survey could sit unanswered without you knowing it existed.

Your risk rating, go-live date and delivery confidence are unchanged — they were already computed from your project's real milestones, blockers and invoices.

05_ship/output/investor-update.md

Customers now see their own project's blockers, not a placeholder

Who it's for: Customers What shipped: The customer dashboard lists the real blockers open on their project, and the satisfaction page says when a survey is waiting. Why it matters: Refine the Bridge is about the quality of each interaction — this dashboard showed everyone the same invented blockers, and the feedback loop never opened.

Both were product bugs; seeding could never have fixed them.

Dig deeper: https://github.com/sustentus/sustentus/pull/814 · https://help.sustentus.com/changelog/2026-08-17-customer-surface-fixes

05_ship/output/release.md

Ship: customer-surface-fixes

  • pr: #814 · merge: authorised — Ready to merge ticked by Jamie; this commit rides the squash
  • CI: green on 6e61fe9Quality Project, Migrate preview database, Audit database and the four pipeline advisories all passed; re-checked after this commit before the merge
  • technical docs: no technical docs impact — the change is confined to a customer surface and the customer-project envelope, and technical/packages/services documents entrypoints, not envelope fields (grepped: no CustomerProjectEnvelope reference anywhere under technical/**)
  • business docs: business/service-journey/delivery — added "What the customer sees on the risk card" under Computed project health (the customer-facing owner filter, and the deliberate badge-vs-list asymmetry it creates), and recorded that a survey_sent lead now surfaces to the customer under "Awaiting your feedback" until they respond
  • release notes: both — changelog entry + ship note
  • sent: queued — ship-note.yaml fires on the squash-merge and mails 05_ship/output/investor-update.md to #product-update. Not sent by this stage.

Acceptance check (vs spec)

  • _customer-fixture.ts gone, nothing imports it — grep, in Verify (agent)
  • Card lists exactly the open customer-facing blockers, with the empty state when there are none — code-traced in Verify; listOpenByLead filters status: "open" and scopes by tenant + lead; RiskBlockersFeedback branches on blockers.length === 0. Not demonstrated on the preview — see the caveat below.
  • Partner/Vendor blockers never appear on the card but still feed computeProjectHealth — added at Verify from a code-review finding (0765dfa); four unit cases assert the split
  • CustomerProjectEnvelope exposes the blockers with no new round-trip — code-traced; the mapper reuses the array already fetched for health
  • A survey_sent lead appears in the strip; other statuses do not — code-traced; the query filters status: { $in: ["survey_sent"] } under existing tenant + customer scoping. Not demonstrated on the preview.
  • Submitting the CSAT removes it from the strip — unchanged existing behaviour (progressStatusPositive moves the lead to completed). Not demonstrated on the preview.
  • No "csat" lead-status filter or guard under customer/satisfaction/ — grep, in Verify
  • Unit coverage for the blocker mapping — seven cases in customer-project/index.test.ts, green in the Quality suite on 6e61fe9
  • No change to admin, CSM, SDM, expert or vendor surfaces — the diff touches only the two customer routes, the customer-project envelope, the delivery doc page and the changelog

Caveat carried into the merge

The five signed-in Definition-of-Done lines in 04_verify/output/verify.md were never demonstrated on the Vercel preview — the agent has no preview credentials, and no operator pass was reported back. They are ticked above on code tracing and unit coverage, not on a human seeing them work. The gate was authorised regardless: Jamie ticked Ready to merge and instructed the ship directly. Recorded here so the run's evidence is not overstated.

The surfaces are also unlit on the preview until the storyline seeder lands (storyline-full-coverage-generator, saas-vendor-storyline, later in this batch) — there are no blocker documents or survey_sent leads to show, so an operator pass today would have confirmed the empty states and little else.

No walkthrough clip was posted to #build: the agent cannot record one, and there is nothing lit to record.

Context budget: within the Inputs table — the spec, verify record, run.md, _shared/github.md, the knowledge map, and the four apps/docs pages checked for impact (service-journey/delivery, feature-role-matrix/projects, technical/packages/services, initiatives/refine-the-bridge + okrs/2026-Q2 for the ship-note tie-in).