Skip to Content

← All archived runs

Run: project-messaging

run.md

Run: project-messaging

  • branch: claude/zealous-ramanujan-n4kugn
  • pr: #507

00_intake/stub.md

Stub: Customer dashboard — project messaging thread

  • feature-slug: project-messaging
  • epic: customer-dashboard-data
  • personas: Customer ↔ delivery team (Expert, CSM, SDM)
  • initiative: Build the Bridge / objective: Q2 2026 Objective 3 — Validate Technical Infrastructure & Payout Flow
  • depends-on: customer-project-foundation
  • sequence: 9 of 9

⚠ Flag: likely epic-sized

A full project chat (threads, real-time delivery, read state, attachments, notifications) is bigger than one feature/PR. This stub scopes only the minimum to power the dashboard — the latest message and a basic readable/sendable thread. Before building, consider running /pipeline decompose on messaging in its own right; treat this stub's scope as the floor, not the ceiling.

Problem

The ProjectStatusStrip's latestMessage (sender, text, time, waitingOn: you | team) is dummy. There is no messaging model. The customer and the delivery team have no shared thread on the project, so the "waiting on you / waiting on team" signal and the latest-message preview cannot be real. Ably (real-time) is already in the stack for this kind of surface.

Proposed change

  • Define a project message thread: messages with sender, text, createdAt, scoped to a project (lead), between the customer and their delivery team.
  • Derive waitingOn (you | team) from who sent the last message / whose reply is outstanding.
  • Wire the latest-message preview in the ProjectStatusStrip, and a basic thread view the customer can read and post into.
  • Real-time delivery via Ably (already a platform dependency) for new messages.

Acceptance criteria (rough)

  • Customer sees the genuine latest message + correct waiting-on signal on the dashboard.
  • Customer can open the thread, read history, and send a message to the delivery team.
  • New messages appear without a manual refresh (Ably).
  • Thread is project- and participant-scoped; non-participants cannot read it.

Out of scope (this feature — candidates for the messaging epic)

  • Attachments, reactions, typing indicators, multiple threads per project.
  • Email fan-out / unread badges / notification preferences.
  • Moderation / message editing & deletion.

Notes for Define

  • Reassess size first — if Define agrees it's epic-sized, cut it via /pipeline decompose and build the latest-message slice as the first child.
  • Decide model shape (thread + messages vs. flat messages) and the Ably channel scoping per project.
  • touches: packages/services/src/db/models (new message/thread model), packages/services/src/server + notifications/Ably, apps/web/components/dashboard/customer/project-status-strip.tsx, apps/web/app/(app)/customer/ (new thread view).

01_define/output/spec.md

Spec: Project messaging thread (customer ↔ delivery team)

  • slug: project-messaging
  • personas: Customer (primary); delivery team — Expert, CSM, SDM (as message participants/data source)
  • touches: packages/services/src/db/models/project-message.ts, packages/services/src/db/models/index.ts, packages/services/src/db/services/project-message/, packages/services/src/db/services/index.ts, packages/services/src/notifications/messaging/, apps/web/app/(app)/customer/dashboard/page.tsx, apps/web/components/dashboard/customer/project-status-strip.tsx, apps/web/app/(app)/customer/messages/
  • complexity: standard

Problem

The customer dashboard's "Talk to your team" card shows a latestMessage (sender, text, time, waitingOn: you | team) that is entirely dummy — it reads from apps/web/lib/mock/customer.ts. There is no messaging model in the platform, so the customer and their delivery team share no thread on the project, and the "waiting on you / waiting on team" signal and the latest-message preview cannot be real. This is the last of the nine customer-dashboard-data features (sequence 9 of 9) that replace mock data with real, per-tenant, per-customer data — it advances Build the Bridge / Q2 2026 Objective 3 (Validate Technical Infrastructure & Payout Flow) by making the customer's delivery-stage communication genuine. Ably (real-time) and a partly-scaffolded notifications/messaging module (a messaging.message-received notification type + notifyMessageReceived) already exist in the stack; this feature is the message model and surfaces that sit on top of them.

Proposed change

Build the minimum project message thread needed to power the dashboard — explicitly the floor, not a full chat product (see Out of scope).

  • Model — a ProjectMessage document scoped to a project (lead) and tenant, mirroring existing model conventions (tenantId, lead ref, tenant + soft-delete plugins, as in blocker.ts): one flat message per document with sender (the authoring user), senderRole (customer | delivery team), text, and createdAt. One implicit thread per project — no separate thread/channel document this round.
  • Service — a project-message service that, for a given tenant + project: lists the message history (oldest→newest), posts a new message, and derives the latest message preview (text, sender, time, waitingOn). waitingOn is computed from the last message's author: customer sent last → team; delivery team sent last → you; no messages yet → team.
  • Participant scoping — only the project's customer and their assigned delivery team (Expert/CSM/SDM on the lead) may read or post. Non-participants get nothing (empty/forbidden), never another project's thread.
  • Customer send (this round) — the customer can open the thread, read history, and post a message. The dashboard's "Ask a question" button and latest-message preview link into a thread view at /customer/messages. Delivery-team replies are deferred (see Out of scope) — until that feature lands the thread may be one-sided, but waitingOn and the preview always derive correctly from the real message history.
  • Wire the dashboard — replace the mock latestMessage in apps/web/app/(app)/customer/dashboard/page.tsx with the real derived preview; the existing ProjectStatusStrip rendering is unchanged (it already renders latestMessage and the waiting-on badge — it just receives real data).
  • Real-time — on a new message, publish via the existing Ably path (publishToAbly to each other participant's user channel) and create a messaging.message-received notification via the existing notifyMessageReceived, so a new message appears without a manual refresh.

Acceptance criteria

  • The customer dashboard shows the genuine latest message (text, sender, time) and the correct waiting-on signal, derived from real ProjectMessage data — no mock.
  • The customer can open the thread view, read the project's message history in order, and post a new message that persists and immediately appears in the thread.
  • A newly posted message appears for other connected participants without a manual refresh (via the existing Ably publish path).
  • The thread is project- and participant-scoped: only the project's customer and assigned delivery team can read it; a non-participant cannot read or post to it, and cannot reach another project's thread.
  • When a project has no messages yet, the dashboard preview and thread degrade gracefully (no crash, sensible empty state) and waitingOn defaults to team.

Out of scope

  • Delivery-team reply UI — no in-app surface for Expert/CSM/SDM to post this round (deferred to a later feature); only the customer authors messages now.
  • Attachments, reactions, typing indicators, read receipts, and multiple threads per project.
  • Email fan-out, unread badges/counts, and notification preferences (beyond the existing notifyMessageReceived in-app notification already created).
  • Message editing, deletion, and moderation.
  • Any of the other five persona dashboards — Customer view only.

Open questions

  • none — realtime channel strategy (reuse the existing per-user Ably channels vs. a new per-project channel) is an implementation choice left to Build; either satisfies the no-refresh acceptance criterion, and reusing the existing per-user publish path is the expected default.

02_build/output/notes.md

Build notes: project-messaging

  • commits: feat: project-messaging — message model + service; feat: project-messaging — thread view, dashboard wiring, realtime

What changed

Data layer (packages/services)

  • src/db/models/project-message.ts — new ProjectMessage model: flat message per document scoped to tenantId + lead, with sender (user ref) + denormalised senderName, senderRole (customer | team), and text. Tenant + soft-delete + schema plugins, mirroring blocker.ts. Compound index { tenantId, lead, createdAt } powers the chronological thread read and the tail (latest) preview.
  • src/db/models/index.ts — export the new model.
  • src/db/services/project-message/{index,instance}.tsProjectMessageService:
    • resolveActiveProject (private) — the single scoping gate: a customer only ever resolves their own isActiveProject lead, so reads/writes can never reach another project's thread. Also collects the delivery-team participant user ids (expert + manager).
    • getThreadForCustomer — full thread (chronological) + latest preview, or null when no project.
    • getLatestForCustomer — lean latest-message preview for the dashboard card.
    • postMessageForCustomer — persists the customer's message, then notifies each delivery-team participant via the existing notifyMessageReceived (in-app notification + per-user Ably publish).
    • waitingOn is derived from the last message's senderRole (customer sent → team; team sent → you; none → team).
  • src/db/services/index.ts — export the service instance, class, and view types.

Web app (apps/web)

  • app/(app)/customer/dashboard/page.tsx — drop the mock latestMessage; fetch the real preview via projectMessageService.getLatestForCustomer (added to the existing Promise.all) and map it into the ProjectStatusStrip prop shape.
  • components/dashboard/customer/project-status-strip.tsx — the "Ask a question" button and the latest-message preview now link to /customer/messages (rendering unchanged otherwise).
  • app/(app)/customer/messages/page.tsx — new server-rendered thread view, customer-role gated, scoped to the signed-in customer's active project; empty state when there is no project.
  • app/(app)/customer/messages/actions.tssendProjectMessageAction ("use server", customer role), composing the standard resolveActionContext / runActionBody / zodToActionError pipeline.
  • components/dashboard/customer/project-message-thread.tsx — client thread: renders history (own messages right-aligned), a composer (Enter to send), optimistic append + router.refresh(), and an Ably subscription on the customer's own tenant:<t>:user:<u> channel that refreshes when a messaging.message-received event lands (the path that lights up when the delivery team replies).

Acceptance criteria status

  • Dashboard shows the genuine latest message + correct waiting-on signal — wired to getLatestForCustomer; no mock. Empty (no messages) renders no preview.
  • Customer can open the thread, read history in order, and post a message that persists and appears immediately — thread view + sendProjectMessageAction + optimistic append.
  • New messages reach other connected participants without a refresh via the existing Ably path — postMessageForCustomer fans out notifyMessageReceived to each delivery-team participant (in-app notification + per-user Ably publish); the customer's thread also subscribes for the reverse direction.
  • Project- and participant-scoped — every read/write goes through resolveActiveProject, which only ever resolves the customer's own active project; a non-participant resolves nothing and a customer cannot reach another project's thread.
  • No messages yet degrades gracefully — service returns null/empty, the dashboard shows no preview, the thread shows an empty state, and waitingOn defaults to team.

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. No check is expected to fail.

Notes for review

  • Customer-send only this round (per the spec's Define decision): the delivery team has no in-app reply surface yet, so threads are one-sided until a follow-up feature adds it. The realtime path and the model already support team messages (senderRole: "team"), so that feature is purely additive.
  • The unused latestMessage field remains in apps/web/lib/mock/customer.ts — it belongs to the mock object other (not-yet-wired) sections still read; left untouched to keep this PR focused.

03_release/output/changelog.md

Changelog: project-messaging

  • entry page: apps/help/app/changelog/2026-06-22-project-messaging/page.mdx
  • personas: customer

Message your delivery team from your project

You can now message your delivery team directly from your project:

  • The "talk to your team" panel on your dashboard shows the latest message and whether the next response is with you or your team.
  • Open the full conversation from your dashboard to read the history and send a new message.
  • New replies appear as they arrive, without refreshing the page.

Your conversation stays private to your project and the team delivering it.

03_release/output/investor-update.md

Customers can now message their delivery team inside the project

Who it's for: Customers and their delivery teams What shipped: A project message thread — the dashboard shows the latest reply and a waiting-on signal, and customers can open it to read history and send messages. Why it matters: Keeps customers and delivery teams in sync during live projects — advancing Build the Bridge toward a complete service journey.

Messages reach the team in real time.

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

03_release/output/release.md

Release: project-messaging

  • pr: #507 (https://github.com/sustentus/sustentus/pull/507) · merged: <pending>
  • CI: <pending — awaiting green on the review-fix commits>
  • technical docs: no technical docs impact (the technical reference documents the services package at folder/architecture level, not individual models/services/routes)
  • business docs: updated in this PR — business/service-journey/delivery (new "Project messaging" section) and business/feature-role-matrix/projects (new "Message delivery team" row)
  • release notes: both — changelog entry (apps/help/app/changelog/2026-06-22-project-messaging) + investor draft in this PR
  • deploy: <pending>
  • sent: <pending>

Review summary

/code-review (high effort) run on the branch diff (code + docs + release drafts). Findings triaged:

  • Dead computed latest on getThreadForCustomer — the thread page never reads it; the dashboard has its own getLatestForCustomer. Removed (field + return). Resolved on branch.
  • Optimistic append + router.refresh() double source of truth — dropped the redundant refresh on send; the returned view is the canonical persisted record, so the optimistic append stands alone. Resolved on branch.
  • scrollIntoView scrolled the whole page — now scrolls only the thread container via a container ref. Resolved on branch.
  • senderName unbounded vs model maxlength: 200 — clamped in the service. Resolved on branch.
  • Messaging preview promoted to a hard dashboard dependencygetLatestForCustomer is in the dashboard's Promise.all, so a transient failure would blank the whole page; previously the preview was static mock. Isolated with .catch(() => null) so it degrades to "no preview". Resolved on branch.
  • Notification has no linkaccepted: the delivery team has no in-app thread surface this round (customer-send only), so there is no correct destination to link yet; it arrives with the team-reply feature.
  • Realtime subscription has no producer this round — accepted: intentional forward-wiring; fires when the deferred team-reply feature publishes to the customer's channel.
  • Double active-project lookup on the dashboard — accepted: one extra indexed findOne; threading the leadId would couple the envelope, not worth it.
  • interface IProjectMessage, fire-and-forget notifyMessageReceived, void subscribe(...) cleanup, raw bg-gray-*accepted: all match established codebase patterns (sibling models, notifications, use-inbox-subscription, the existing strip).

Acceptance check (vs spec)

  • Dashboard shows the genuine latest message + correct waiting-on signal — wired to getLatestForCustomer; no mock.
  • Customer opens the thread, reads history in order, posts a message that persists and appears immediately — thread view + sendProjectMessageAction + optimistic append.
  • New messages reach other connected participants without refresh via the existing Ably path — postMessageForCustomer fans out notifyMessageReceived to each delivery-team participant.
  • Project- and participant-scoped — every read/write goes through resolveActiveProject (customer's own active project only); non-participants resolve nothing.
  • No messages yet degrades gracefully — service returns null/empty; dashboard shows no preview; thread shows an empty state.