feat(im): message forwarding (single + multi-select bundle) - #101
feat(im): message forwarding (single + multi-select bundle)#101winrey wants to merge 23 commits into
Conversation
Introduces grant + request primitives with subject kinds (agent / channel-session / execution-session / task), JSONB scope metadata, spell-id-identified one-time approvals, and a central gate() check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Designs a forward-message feature that quotes one message or bundles N messages from the same channel into one forward-type message at the destination, with denormalized source-location columns so agents can trace forwards back to their origin even after source deletion. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Approvers now resolved per-key from the resource holder, with optional AI-suggested approvers and workspace-owner safety net. - Spell-id wordlist switched to BIP-39 (2048 mnemonic words). - Grant expiry has no upper bound. - First enforcement point selected: bot cross-channel messages:send, shipped together with the framework in PR 1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Decomposes the design spec into 14 bite-sized tasks (Task 0..Task 13) covering i18n, schema, access helper, ForwardsService, controller, read-path hydration, e2e, frontend types/store/dialog/cards/selection mode, and final manual smoke + PR. Includes co-located .tasks.json so executing-plans can resume from any session. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the `forward` enum value to `message_type` and the `im_message_forwards` table with cascade/set-null FK rules, 4 indexes, and a structural schema spec (7 passing assertions). Migration 0058_strong_rage.sql applied to local dev DB. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move the inline isMember / isActivated / isArchived guard from MessagesController.createChannelMessage into a new reusable ChannelsService.assertWriteAccess helper, keeping error strings byte-identical. Add unit tests covering all five branches (happy path + four rejection paths). Update the controller spec to mock at the assertWriteAccess boundary instead of the individual sub-calls. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements Task 3 of message-forwarding: ForwardsService with full validation, snapshot building, gRPC message creation, im_message_forwards row insertion, rollback on insert failure, getForwardItems, hydratePayload. Adds findManyByIds/getAttachmentsForMessages/findUsersByIds/softDelete to MessagesService and canRead/findManyByIds to ChannelsService. Updates CreateMessageDto to accept 'forward' type. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Expose POST /api/v1/im/channels/:targetChannelId/forward and GET /api/v1/im/messages/:id/forward-items as a thin controller wrapper around ForwardsService; register ForwardsController in MessagesModule. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…orwards
- Add FORWARDS_SERVICE symbol injection token and lazy moduleRef getter in MessagesService to resolve circular ESM dependency
- MessageResponse.type union extended with 'forward'; forward?: ForwardPayload field added
- getMessageWithDetails(messageId, userId?) hydrates forward field via ForwardsService.hydratePayload when type==='forward' and userId provided
- hydrateForwardsBatch private helper; all bulk read methods (getChannelMessages, getChannelMessagesPaginated, getThread, getSubReplies) accept optional userId and call batch hydration
- MessagesService.update throws BadRequestException('forward.editDisabled') when target type is 'forward'
- All controller call sites thread userId through
- ForwardsService uses forwardRef for MessagesService injection to satisfy NestJS DI
- New tests: forward hydration happy path, skip when non-forward, skip when no userId, batch hydration, PATCH rejection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds forward.e2e-spec.ts that bootstraps a minimal Nest app with ForwardsController + mocked ForwardsService, exercises the actual HTTP routes via supertest, and verifies route URLs, AuthGuard, ParseUUIDPipe, ValidationPipe DTO constraints (empty/too-many/non-UUID), and correct service delegation for both the POST forward and GET forward-items routes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Extend MessageType union with 'forward' - Add ForwardPayload, ForwardItem, ForwardAttachmentSnapshot to im.ts - Add forward? field to Message interface - Add api.forward.create and api.forward.getItems in services/api/forward.ts - Add useForwardSelectionStore with enter/exit/toggle/addRange/clear/isSelected - Export FORWARD_SELECTION_MAX = 100 - 100% line + branch coverage on store file (13 tests) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements ForwardDialog (channel selector + confirmation), ForwardChannelList (searchable, excludes archived/deactivated/source channel), and ForwardPreview (single-message quote vs bundle summary). All three components covered at 100% Stmt/Branch/Func/Lines. Adds forward.success i18n key to en and zh-CN locales. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add ForwardItemBody (shared item renderer), ForwardedMessageCard (quote-style single / stacked bundle with click-to-expand), and ForwardBundleViewer (lazy-fetch modal) for the receiving end of a forward. 100% line + branch coverage on all three files. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wire the forward-selection Zustand store into the channel message UI: - MessageItem: per-row checkbox in selection mode, click-to-toggle, shift-click range selection, suppressed hover toolbar + context menu - MessageList: Esc exits selection, channel-change exits selection, SelectionActionBar + ForwardDialog rendered at bottom - Tests: 6 new selection-mode tests in MessageList.test.tsx covering bar visibility, Esc exit, channel-change exit, dialog open/close Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add Forward and Select entry points to MessageHoverToolbar and MessageContextMenu via new forwardable/onForward/onSelect props. MessageItem wires both callbacks: handleForward opens a local ForwardDialog, handleSelect calls the selection store. Tests cover visibility rules and click handlers for both components (17 new tests). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
🚅 Deployed to the team9-pr-101 environment in Team9
|
Backend - Single + bundle digests now embed sender display name (spec §3.3); pre-loads names via findUsersByIds, falls back to "unknown" when the user record is absent. Adds 3 dedicated digest tests. Frontend - ForwardDialog: corrected TanStack Query invalidation key from ["channelMessages", id] (no such query) to ["messages", id] — fixes a silent no-op where the destination channel did not refresh after a successful forward. - ForwardBundleViewer: distinct loading text via new forward.bundle.loading i18n key (en + zh-CN); previously reused forward.source.unavailable which made loaders look like permanent failures. Also defensive .sort() on items by position. - MessageItem: shift+click range cap toast now distinguishes cap-hit from harmless dedup (was firing whenever range had any already- selected ids). Anchor no longer updates when toggle is rejected. - MessageList: Enter shortcut opens the forward dialog when ≥1 message selected (spec §6.3); ignored while focus is in an editable element. - ForwardItemBody: dedicated test file (13 cases) covers sender name fallback, AST vs plaintext branch, attachment rendering, jump button visibility rules, and onJump invocation.
There was a problem hiding this comment.
Pull request overview
Implements message forwarding end-to-end across the Team9 IM stack, including backend persistence + APIs, message hydration on reads, and client UI for single-message forwarding and multi-select “bundle/chat record” forwarding.
Changes:
- Backend: adds
message_type='forward', newim_message_forwardssnapshot table + migration, and new forward endpoints (POST .../channels/:id/forward,GET .../messages/:id/forward-items) with hydration support inMessagesService. - Frontend: adds forward types + API client, forwarding dialog + forwarded-message rendering, and a selection-mode UX (checkboxes, action bar, shift+click range, Esc/route-change exit).
- Tests/i18n: adds extensive unit/integration coverage and new
forward.*locale strings.
Reviewed changes
Copilot reviewed 57 out of 59 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/superpowers/plans/2026-05-02-message-forwarding.md.tasks.json | Tracks the implementation plan + verification checklist for message forwarding. |
| apps/server/libs/shared/src/types/message.types.ts | Extends shared DTO message type union with 'forward'. |
| apps/server/libs/database/src/schemas/im/messages.ts | Adds 'forward' to the Postgres message_type enum. |
| apps/server/libs/database/src/schemas/im/message-forwards.ts | Introduces Drizzle schema for im_message_forwards snapshot storage. |
| apps/server/libs/database/src/schemas/im/message-forwards.spec.ts | Adds structural schema tests for forward table + enum ordering. |
| apps/server/libs/database/src/schemas/im/index.ts | Exports the new message-forwards schema module. |
| apps/server/libs/database/migrations/meta/_journal.json | Registers the new migration in the Drizzle journal. |
| apps/server/libs/database/migrations/0058_strong_rage.sql | Migration to add enum value + create im_message_forwards table + indexes/FKs. |
| apps/server/apps/gateway/test/forward.e2e-spec.ts | HTTP-level integration tests for forward endpoints (guards/pipes/validation/delegation). |
| apps/server/apps/gateway/src/shared/constants/injection-tokens.ts | Adds FORWARDS_SERVICE injection token for lazy resolution/cycle avoidance. |
| apps/server/apps/gateway/src/im/messages/messages.service.ts | Adds forward payload hydration on reads, PATCH rejection for forward messages, and helper methods used by ForwardsService. |
| apps/server/apps/gateway/src/im/messages/messages.service.spec.ts | Updates mocks and adds tests for forward hydration + new helper methods + forward PATCH guard. |
| apps/server/apps/gateway/src/im/messages/messages.module.ts | Wires ForwardsService/ForwardsController and provides FORWARDS_SERVICE token. |
| apps/server/apps/gateway/src/im/messages/messages.controller.ts | Refactors write checks via ChannelsService.assertWriteAccess and threads userId to message-read paths for forward hydration. |
| apps/server/apps/gateway/src/im/messages/messages.controller.spec.ts | Updates controller tests for assertWriteAccess usage and new userId plumbing + forward edit-disabled propagation. |
| apps/server/apps/gateway/src/im/messages/forwards/types.ts | Defines forward payload/metadata/item types + limits/constants. |
| apps/server/apps/gateway/src/im/messages/forwards/forwards.service.ts | Implements core forwarding logic: validation, snapshot capture, row insert, item hydration, and payload hydration. |
| apps/server/apps/gateway/src/im/messages/forwards/forwards.controller.ts | Adds forward REST endpoints (POST forward, GET forward-items) guarded by AuthGuard. |
| apps/server/apps/gateway/src/im/messages/forwards/forwards.controller.spec.ts | Controller unit tests verifying delegation and argument shaping. |
| apps/server/apps/gateway/src/im/messages/forwards/dto/create-forward.dto.ts | DTO validation for forward creation (UUIDs, 1..100 ids, optional clientMsgId). |
| apps/server/apps/gateway/src/im/channels/channels.service.ts | Adds assertWriteAccess plus non-throwing canRead and bulk findManyByIds helpers for forwards hydration. |
| apps/server/apps/gateway/src/im/channels/channels.service.spec.ts | Adds coverage for assertWriteAccess, canRead, and findManyByIds. |
| apps/client/src/types/im.ts | Adds forward message type and forward payload/item snapshot types to client models. |
| apps/client/src/stores/useForwardSelectionStore.ts | Introduces Zustand selection store for multi-select forwarding (cap=100, range add, toggle/enter/exit). |
| apps/client/src/stores/tests/useForwardSelectionStore.test.ts | Unit tests for selection store behavior and cap enforcement. |
| apps/client/src/services/api/index.ts | Exposes the new forward API module via the API facade. |
| apps/client/src/services/api/forward.ts | Implements forward create + forward-items fetch API calls. |
| apps/client/src/i18n/locales/zh-CN/channel.json | Adds zh-CN forward.* strings for toolbar/menu/dialog/cards/errors. |
| apps/client/src/i18n/locales/en/channel.json | Adds en forward.* strings for toolbar/menu/dialog/cards/errors. |
| apps/client/src/components/channel/MessageList.tsx | Integrates selection mode, action bar, and forward dialog orchestration at list level. |
| apps/client/src/components/channel/MessageItem.tsx | Adds per-row selection UI, forward/select entry points in toolbar/context menu, and shift+click range selection wiring. |
| apps/client/src/components/channel/MessageHoverToolbar.tsx | Adds Forward + Select buttons to the hover toolbar (gated by forwardable). |
| apps/client/src/components/channel/MessageContextMenu.tsx | Adds Forward + Select actions to context menu (gated by forwardable). |
| apps/client/src/components/channel/MessageContent.tsx | Dispatches forward-type messages to <ForwardedMessageCard />. |
| apps/client/src/components/channel/forward/SelectionActionBar.tsx | Sticky bottom action bar for selection mode (count + forward/cancel). |
| apps/client/src/components/channel/forward/ForwardPreview.tsx | Renders single/bundle preview content inside the forward dialog. |
| apps/client/src/components/channel/forward/ForwardItemBody.tsx | Renders a forward item (sender/time/body/attachments + optional jump link). |
| apps/client/src/components/channel/forward/ForwardedMessageCard.tsx | Renders forwarded messages (single quote card or bundle card + modal viewer). |
| apps/client/src/components/channel/forward/ForwardDialog.tsx | Channel picker + preview + mutation + toast/error mapping for forwarding. |
| apps/client/src/components/channel/forward/ForwardChannelList.tsx | Searchable eligible-channel list for the forward dialog (filters archived/deactivated/source). |
| apps/client/src/components/channel/forward/ForwardBundleViewer.tsx | Modal viewer that lazy-fetches forward items and renders them. |
| apps/client/src/components/channel/forward/eligibility.ts | Eligibility checks + forwardable range computation for selection mode. |
| apps/client/src/components/channel/forward/tests/SelectionActionBar.test.tsx | Tests selection action bar rendering and callbacks. |
| apps/client/src/components/channel/forward/tests/ForwardPreview.test.tsx | Tests forward preview rendering for single/bundle cases. |
| apps/client/src/components/channel/forward/tests/ForwardItemBody.test.tsx | Tests item body rendering (ast/plaintext/attachments/jump link). |
| apps/client/src/components/channel/forward/tests/ForwardedMessageCard.test.tsx | Tests forwarded message card rendering, bundle viewer open/close, jump behavior. |
| apps/client/src/components/channel/forward/tests/ForwardDialog.test.tsx | Tests dialog behavior, API calls, toasts, error mapping, and query invalidation. |
| apps/client/src/components/channel/forward/tests/ForwardChannelList.test.tsx | Tests channel filtering/search/selection behaviors. |
| apps/client/src/components/channel/forward/tests/ForwardBundleViewer.test.tsx | Tests bundle viewer query states, rendering, jump link, and close behavior. |
| apps/client/src/components/channel/forward/tests/eligibility.test.ts | Tests eligibility logic and range computation. |
| apps/client/src/components/channel/tests/MessageList.test.tsx | Adds selection-mode integration tests around action bar, Esc exit, channel-change exit, and dialog open/success. |
| apps/client/src/components/channel/tests/MessageHoverToolbar.test.tsx | Adds tests for forward/select button visibility and click handlers. |
| apps/client/src/components/channel/tests/MessageContextMenu.test.tsx | Adds tests for forward/select context menu items visibility and click handlers. |
| apps/client/src/components/channel/tests/MessageContent.forward.test.tsx | Tests MessageContent dispatches forward messages to the forwarded-card renderer. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const message = | ||
| await this.messagesService.getMessageWithDetails(forwardedMessageId); | ||
| return this.messagesService.truncateForPreview(message); |
There was a problem hiding this comment.
Fixed in aaad162 — getMessageWithDetails(forwardedMessageId, userId) is now called with userId so the response is hydrated. New test in forwards.service.spec.ts covers it.
| // --- Load source messages --- | ||
| const sourceMessages = | ||
| await this.messagesService.findManyByIds(sourceMessageIds); | ||
| if (sourceMessages.length !== sourceMessageIds.length) { | ||
| throw new NotFoundException('forward.notFound'); | ||
| } |
There was a problem hiding this comment.
Fixed in aaad162 — Array.from(new Set(input.sourceMessageIds)) dedupes at the top of forward(). New regression test asserts duplicates collapse and a single-forward is created.
| const sourceStillExists = | ||
| !!r.sourceMessageId && liveSourceIds.has(r.sourceMessageId); | ||
| const truncated = | ||
| !!r.contentSnapshot && | ||
| r.contentSnapshot.length === FORWARD_CONTENT_SNAPSHOT_LIMIT; | ||
|
|
There was a problem hiding this comment.
Declined — truncation flag misreport requires source content of exactly 100,000 characters, which would already be classified as long_text upstream. The flag is a UI hint, not a correctness gate. Adding a persisted column for this edge case is not justified at the V1 scope. Recording in the finish-feature decisions report.
| const inSelectionMode = | ||
| selectionActive && selectionChannelId === message.channelId; | ||
| const isEligible = isForwardable(message); | ||
| const isSelected = inSelectionMode && isSelectedFn(message.id); | ||
| const lastAnchorRef = useRef<string | null>(null); | ||
|
|
||
| // Forward dialog state | ||
| const [forwardOpen, setForwardOpen] = useState(false); | ||
| const handleForward = useCallback(() => setForwardOpen(true), []); | ||
| const handleSelect = useCallback(() => { | ||
| const store = useForwardSelectionStore.getState(); | ||
| store.enter(message.channelId); | ||
| store.toggle(message.id); | ||
| }, [message.channelId, message.id]); | ||
|
|
||
| const toggleSelection = useCallback( | ||
| (shiftKey: boolean) => { | ||
| if (!isEligible) return; | ||
| if (shiftKey && lastAnchorRef.current && visibleMessages) { | ||
| const range = computeForwardableRange( | ||
| visibleMessages, | ||
| lastAnchorRef.current, | ||
| message.id, | ||
| ); | ||
| // Compute how many ids the range *would* add (excluding ones already | ||
| // selected) so we can distinguish a cap-hit from harmless dedup. | ||
| const beforeIds = useForwardSelectionStore.getState().selectedIds; | ||
| const wouldAdd = range.filter((id) => !beforeIds.has(id)).length; | ||
| const added = selectionAddRange(range); | ||
| if (added < wouldAdd) { | ||
| toast.error(t("channel:forward.tooManySelected")); | ||
| } | ||
| } else { | ||
| const ok = selectionToggle(message.id); | ||
| if (!ok) { | ||
| toast.error(t("channel:forward.tooManySelected")); | ||
| // Don't update the anchor when the toggle was rejected — keeping | ||
| // the previous anchor avoids surprising shift+click ranges that | ||
| // start from a message the user could not actually select. | ||
| return; | ||
| } | ||
| lastAnchorRef.current = message.id; | ||
| } |
There was a problem hiding this comment.
Fixed in aaad162 — anchor is now anchorId on useForwardSelectionStore. Toggle updates it, addRange preserves it, enter/exit/clear reset it. 8 new store tests cover the lifecycle.
…EADME Test gaps from completeness audit: - Add apps/client/src/services/api/__tests__/forward.test.ts (7 cases — URL/body shape, optional clientMsgId, normalizeMessage passthrough, http error propagation for both create and getItems). - Add 3 MessageList tests for the Enter-key shortcut: opens dialog when selection >= 1; ignored when nothing selected; ignored when focus is inside an editable element (textarea). Docs: - CLAUDE.md: add `forward` to message-type enumeration, add `message_forwards` to im/ table list, add Forwarding bullet to Message Features section. - README.md: extend Rich Messaging feature bullet with "message forwarding".
- forwards.service: pass userId to getMessageWithDetails after creating the forward so the POST response carries the hydrated `forward` payload (otherwise the client renders a blank card until refetch). - forwards.service: dedupe sourceMessageIds at entry — previously passing [id, id, id] (or any duplicate) triggered a spurious forward.notFound because findManyByIds returns unique rows. - useForwardSelectionStore + MessageItem: lift the shift+click anchor from a per-MessageItem useRef into the store as `anchorId`. The old layout meant clicking message A then shift+clicking message B saw B's instance ref still null, so the range branch never fired. The anchor now updates on toggle, persists across addRange, resets on enter/exit/clear, and is exposed via setAnchor. Tests - forwards.service.spec: 2 new cases — dedupes duplicate ids, passes userId to getMessageWithDetails. - useForwardSelectionStore.test: 8 new cases covering anchor lifecycle. Declined (recorded in final decisions report): - Copilot Comment 3 (truncated === length === LIMIT misreport): the edge case requires content of exactly 100k characters, which would be classified as `long_text` upstream and is vanishingly rare. The truncated flag is a UI hint, not a correctness gate. Adding a persisted column for this is not justified.
Sensitive Decisions Report (Full Auto)This PR was finalized via the
What was applied
|
Summary
Adds single-message forwarding and multi-select bundle forwarding across the Team9 IM stack.
Jump to originallink when source still readable.im_message_forwardstable denormalizessourceChannelId/sourceWorkspaceId/sourceSenderId/sourceCreatedAt/sourceSeqIdso an agent can locate the original conversation position even if the source message is hard-deleted.Spec: docs/superpowers/specs/2026-05-02-message-forwarding-design.md
Plan: docs/superpowers/plans/2026-05-02-message-forwarding.md
Architecture
messageType = 'forward'enum value + new tableim_message_forwards(one row per single, N per bundle, with content/attachment snapshots).POST /api/v1/im/channels/:id/forwardandGET /api/v1/im/messages/:id/forward-itemsendpoints.MessagesService.getMessageWithDetails(and bulk paths) hydrateforwardpayload viaForwardsService.hydratePayloadfortype === 'forward'. PATCH on a forward message is rejected withforward.editDisabled.messages.metadata.forwardrecord and Nim_message_forwardsrows — no realim_message_attachmentsrows on the forward message; attachments live in the snapshot JSON.WS_EVENTS.MESSAGE.NEWis reused; clients dispatch ontype === 'forward'to render<ForwardedMessageCard>.ChannelsService.assertWriteAccessextracts the inline checksMessagesController.createChannelMessagewas duplicating; the forward endpoint andcreateMessageboth go through it.useForwardSelectionStore.anchorIdso it's shared acrossMessageIteminstances.Quality Checklist
50a6e525)Review loop (Claude)— skipped (controller decision: redundant with the Spec/Quality audit just completed; recorded in decisions report)a5917569)aaad162c, 1 declined with reasoning recorded)Codex review— skipped (controller decision: redundant with prior reviews; recorded in decisions report)forwardtype +message_forwardstable + Forwarding feature bullet; README adds forwarding to Rich Messaging)dev)CI Status
GitHub Actions: lint ✅, typecheck ✅, wiki-integration ✅, ahand-persistence-integration ✅, Playwright ✅, team9-app-web Railway preview ✅.
The
testjob's 3 failures are inapps/server/apps/gateway/src/im/channels/effective-membership-wiring.spec.ts— verified pre-existing ondevHEAD (commit453043e8, the merge-base) before this branch was created. NOT introduced by this PR. Recommend addressing in a separate fix PR.The Railway preview deploys for
API-Gateway,Im-worker,Task-workerfailed; preview deploys are environmental and unrelated to source correctness —team9-app-web(the only frontend preview) succeeded.Test Plan
Backend — 308+ unit tests covering service / controller / DTO / module / hydration / PATCH-guard / e2e HTTP-integration. New cases added in this PR's review loop: digest-with-sender-name (3), duplicate-id dedupe (1), userId-passed-to-getMessageWithDetails (1).
Frontend — 156+ unit tests covering store (21 incl. anchor lifecycle), eligibility helper, dialog, channel list, preview, forwarded card, bundle viewer, item body (13), selection action bar, message hover toolbar (forward+select), context menu (forward+select), MessageList (selection mode + Esc + Enter + dialog open), MessageContent dispatch, api/forward (7). 100% line + branch coverage on every new file.
Manual (recommended before merge — not done by AI)
MessageIteminstances (this was a Copilot finding fixed by lifting the anchor into the store)Decisions taken under automation=full
The full decisions report (including reasoning for the skipped review-loop / Codex steps and the declined truncation-flag Copilot comment) is included as a comment on this PR after merge.
🤖 Generated with Claude Code