project-messagingrun.md00_intake/stub.mdA 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.
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.
sender, text, createdAt, scoped to a
project (lead), between the customer and their delivery team.waitingOn (you | team) from who sent the last message / whose reply is outstanding./pipeline decompose and
build the latest-message slice as the first child.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.mdThe 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.
Build the minimum project message thread needed to power the dashboard — explicitly the floor, not a full chat product (see Out of scope).
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.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.lead) may read or post. Non-participants get nothing (empty/forbidden), never
another project's thread./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.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).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.ProjectMessage data — no mock.waitingOn defaults to team.notifyMessageReceived in-app notification already created).02_build/output/notes.mdpackages/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}.ts — ProjectMessageService: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.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.ts — sendProjectMessageAction ("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).getLatestForCustomer; no mock. Empty (no messages) renders no preview.sendProjectMessageAction + optimistic append.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.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.null/empty, the dashboard shows no
preview, the thread shows an empty state, and waitingOn defaults to team.senderRole: "team"), so that feature is purely additive.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.mdYou can now message your delivery team directly from your project:
Your conversation stays private to your project and the team delivering it.
03_release/output/investor-update.mdWho 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/code-review (high effort) run on the branch diff (code + docs + release drafts). Findings triaged:
latest on getThreadForCustomer — the thread page never reads it; the dashboard has its own getLatestForCustomer. Removed (field + return). Resolved on branch.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.getLatestForCustomer 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.link — accepted: 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.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).getLatestForCustomer; no mock.sendProjectMessageAction + optimistic append.postMessageForCustomer fans out notifyMessageReceived to each delivery-team participant.resolveActiveProject (customer's own active project only); non-participants resolve nothing.