From e369457bdffc69a68498914bf44433d865ac9fe8 Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 2 May 2026 08:03:53 +0800 Subject: [PATCH 01/23] docs: spec for permissions and approvals system 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) --- ...-05-02-permissions-and-approvals-design.md | 478 ++++++++++++++++++ 1 file changed, 478 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-02-permissions-and-approvals-design.md diff --git a/docs/superpowers/specs/2026-05-02-permissions-and-approvals-design.md b/docs/superpowers/specs/2026-05-02-permissions-and-approvals-design.md new file mode 100644 index 00000000..c39893f0 --- /dev/null +++ b/docs/superpowers/specs/2026-05-02-permissions-and-approvals-design.md @@ -0,0 +1,478 @@ +# Permissions and Approvals System — Design + +**Date:** 2026-05-02 +**Status:** Draft (pending user approval) +**Author:** Claude (auto-mode brainstorming) + +## 1. Background + +Team9 currently has no general-purpose authorization layer beyond JWT + workspace role guards (`WorkspaceRoleGuard` with owner/admin/member/guest tiers) and channel-level role on `im_channel_members`. Bot capabilities live as a flexible JSONB blob on `im_bots.capabilities` but cannot be scoped per-channel, per-routine, or per-tool, and there is no mechanism for an agent to **ask** for elevated permission. + +`routine__interventions` exists for in-routine pause/approval, but it is tied to a single routine execution lifecycle and does not generalize to ad-hoc capability/data permissions. + +This spec introduces: + +- A **Grant** primitive — a user proactively gives an agent / chat session / routine a permission, optionally scoped via metadata. +- A **Permission Request** primitive — an agent asks for a permission it does not have, identified by a memorable **Spell ID**, and a user approves once or remembers it as a Grant. +- A central **`PermissionsService.gate(...)`** entry point that callers (services, websocket handlers, routine steps) invoke before performing sensitive actions. + +Out of scope for v1: claw-hive runtime tool-call hooks (will be added in a follow-up); tenant-wide policy DSL; delegation chains. + +## 2. Goals & Non-Goals + +### Goals + +- Users can grant a permission to (a) an agent, (b) a chat session (channel), (c) a routine execution, (d) a routine definition (task) — with optional metadata scope. +- Agents can request a permission with a clearly-identifiable Spell ID; users approve `once` / `remember (durable)` / `deny`. +- Single canonical check function `gate(...)` used at every enforcement point. +- WebSocket events let inboxes & settings UIs update live. +- Auditable: every grant and decision recorded with actor + timestamp. + +### Non-Goals + +- Role hierarchy / role inheritance (workspace role guard handles its own surface area). +- Cross-tenant grants. +- Replacing `routine__interventions` (kept for in-flow pauses). +- Automatic grant inference from past behavior. + +## 3. Subjects (who gets a grant) + +| `subject_kind` | `subject_id` references | Lifetime | Use case | +| ------------------- | ------------------------ | -------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `agent` | `im_bots.id` | Long-lived (until bot deleted / grant revoked / expired) | "Always let this assistant read the wiki." | +| `channel-session` | `im_channels.id` | Per-channel (until channel archived) | "In this support thread the agent may invoke the SQL tool." | +| `execution-session` | `routine__executions.id` | Until execution completes | "Just for this run, let the agent post in #ops." | +| `task` | `routine__routines.id` | Until routine deleted | "Every execution of the daily-report routine may read the analytics warehouse." | + +> **Note on terminology:** the user's request uses "session" generically; we split it into chat-session vs execution-session because they have different lifetimes and audit semantics. + +## 4. Permission Keys & Metadata Schema + +Permission keys are **defined in code** (not a DB table), with each key declaring a JSONSchema for valid `scope_metadata`. v1 ships a small set; new keys are added by adding a registry entry. + +```ts +// apps/server/apps/gateway/src/permissions/permission-keys.ts +export const PERMISSION_KEYS = { + "messages:send": { metadata: ChannelScopeSchema, risk: "low" }, + "messages:read": { metadata: ChannelScopeSchema, risk: "low" }, + "tools:invoke": { metadata: ToolScopeSchema, risk: "medium" }, + "wiki:read": { metadata: WikiScopeSchema, risk: "low" }, + "wiki:write": { metadata: WikiScopeSchema, risk: "high" }, + "files:read": { metadata: PathScopeSchema, risk: "medium" }, + "files:write": { metadata: PathScopeSchema, risk: "high" }, + "routine:trigger": { metadata: RoutineScopeSchema, risk: "medium" }, +} as const; +``` + +Scope schemas are intersection-style: each property is optional; presence narrows scope; absence means unrestricted. + +```jsonc +// ChannelScopeSchema example +{ + "channelIds": ["uuid", "..."], // optional whitelist + "channelTypes": ["public", "direct"] // optional whitelist +} +// ToolScopeSchema example +{ + "toolNames": ["sql_query", "fetch"], + "targets": ["staging"] // free-form labels per tool +} +``` + +A **`PermissionMatcher`** compares request metadata against grant `scope_metadata`: + +- Field absent in grant → unrestricted on that field. +- Array → request value must be `∈` array. +- String → exact match. +- Glob (prefix `glob:`) → minimatch. + +All matching logic lives in one file (`permission-matcher.ts`) with unit tests. + +## 5. Database Schema + +New schema folder: `apps/server/libs/database/src/schemas/permissions/`. Tables prefixed `auth_` for visibility. + +### 5.1 `auth_permission_grants` + +```ts +export const authPermissionGrants = pgTable( + "auth_permission_grants", + { + id: uuid("id").defaultRandom().primaryKey(), + tenantId: uuid("tenant_id") + .notNull() + .references(() => tenants.id, { onDelete: "cascade" }), + grantedByUserId: uuid("granted_by_user_id") + .notNull() + .references(() => imUsers.id), + subjectKind: subjectKindEnum("subject_kind").notNull(), + subjectId: uuid("subject_id").notNull(), + permissionKey: text("permission_key").notNull(), + scopeMetadata: jsonb("scope_metadata") + .$type>() + .default({}), + source: grantSourceEnum("source").notNull(), // 'proactive' | 'request_approved' + requestId: uuid("request_id").references(() => authPermissionRequests.id), + expiresAt: timestamp("expires_at"), + revokedAt: timestamp("revoked_at"), + revokedByUserId: uuid("revoked_by_user_id").references(() => imUsers.id), + note: text("note"), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (t) => ({ + bySubject: index("auth_grants_subject_idx").on( + t.tenantId, + t.subjectKind, + t.subjectId, + t.permissionKey, + ), + active: index("auth_grants_active_idx") + .on(t.tenantId, t.permissionKey) + .where(sql`${t.revokedAt} IS NULL`), + }), +); +``` + +Enums: + +- `subject_kind`: `agent` | `channel-session` | `execution-session` | `task` +- `source`: `proactive` | `request_approved` + +### 5.2 `auth_permission_requests` + +```ts +export const authPermissionRequests = pgTable( + "auth_permission_requests", + { + id: uuid("id").defaultRandom().primaryKey(), + spellId: text("spell_id").notNull().unique(), + tenantId: uuid("tenant_id") + .notNull() + .references(() => tenants.id, { onDelete: "cascade" }), + requesterBotId: uuid("requester_bot_id") + .notNull() + .references(() => imBots.id, { onDelete: "cascade" }), + contextChannelId: uuid("context_channel_id").references( + () => imChannels.id, + { onDelete: "set null" }, + ), + contextExecutionId: uuid("context_execution_id").references( + () => routineExecutions.id, + { onDelete: "set null" }, + ), + contextRoutineId: uuid("context_routine_id").references( + () => routineRoutines.id, + { onDelete: "set null" }, + ), + permissionKey: text("permission_key").notNull(), + requestedMetadata: jsonb("requested_metadata") + .$type>() + .default({}), + reason: text("reason"), + status: requestStatusEnum("status").notNull().default("pending"), + decidedByUserId: uuid("decided_by_user_id").references(() => imUsers.id), + decidedAt: timestamp("decided_at"), + decisionNote: text("decision_note"), + durableGrantId: uuid("durable_grant_id").references( + () => authPermissionGrants.id, + ), + consumedAt: timestamp("consumed_at"), // for 'approved_once' + expiresAt: timestamp("expires_at").notNull(), // default now()+30min + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (t) => ({ + spellIdx: uniqueIndex("auth_req_spell_idx").on(t.spellId), + pendingByBot: index("auth_req_pending_bot_idx").on( + t.tenantId, + t.requesterBotId, + t.status, + ), + pendingByContext: index("auth_req_pending_ctx_idx").on( + t.tenantId, + t.contextChannelId, + t.status, + ), + }), +); +``` + +Enum `request_status`: `pending` | `approved_once` | `approved_durable` | `denied` | `expired` | `cancelled`. + +A scheduled job (or lazy lookup-time check) flips stale `pending` rows to `expired`. + +### 5.3 Migration plan + +1. `pnpm db:generate` after committing schema. +2. Single migration `00XX_permissions_init.sql` creates enums + both tables + indexes. +3. No backfill — system starts empty. + +## 6. Spell ID Service + +Located at `apps/server/apps/gateway/src/permissions/spell-id.service.ts`. + +```ts +@Injectable() +export class SpellIdService { + generate(opts?: { wordCount?: 3 | 4 }): string; // default 3, escalates to 4 on collision + parse(input: string): string | null; // trims, lowercases, collapses whitespace +} +``` + +**Word list:** ~200 lowercase blockchain/crypto-themed words (3-7 chars), kept in `spell-words.ts`. Curated for memorability and avoidance of homophones. Examples: `ledger`, `shard`, `hash`, `mint`, `forge`, `crystal`, `flame`, `raven`, `storm`, `sigil`, `oracle`, `beacon`, `zenith`, `ember`, `cipher`, `vault`, `chain`, `block`, `node`, `gas`, `key`, `seal`, `rune`, `glyph`, `prism`, `ether`, `omen`, `relic`. + +**Collision:** `generate()` calls until a unique row insert succeeds (DB unique constraint is the source of truth). Bumps `wordCount` from 3 → 4 → 5 if needed. + +**Format:** lowercase letters + single spaces; regex `^[a-z]+( [a-z]+){2,4}$`. The Spell ID is **not** a security secret — it's a memorable handle. Authentication still goes through normal JWT. + +## 7. Decision & Check Algorithms + +### 7.1 `gate({ key, metadata, ctx })` — central entry + +``` +input ctx: { tenantId, botId, channelId?, executionId?, routineId?, userId? } + +1. Resolve candidate grants: + SELECT * FROM auth_permission_grants WHERE + tenant_id = ctx.tenantId AND + permission_key = key AND + revoked_at IS NULL AND + (expires_at IS NULL OR expires_at > now()) AND + ( + (subject_kind='execution-session' AND subject_id=ctx.executionId) + OR (subject_kind='channel-session' AND subject_id=ctx.channelId) + OR (subject_kind='task' AND subject_id=ctx.routineId) + OR (subject_kind='agent' AND subject_id=ctx.botId) + ) + ORDER BY specificity_rank(subject_kind) DESC + +2. For each candidate, run PermissionMatcher(metadata, grant.scope_metadata). + First match -> return { allowed: true, via: 'grant', grantId }. + +3. If no match, look for a one-time approval: + SELECT * FROM auth_permission_requests WHERE + tenant_id=ctx.tenantId AND requester_bot_id=ctx.botId + AND permission_key=key AND status='approved_once' AND consumed_at IS NULL + AND expires_at > now() + AND (context_channel_id IS NULL OR context_channel_id=ctx.channelId) + AND (context_execution_id IS NULL OR context_execution_id=ctx.executionId) + ORDER BY decided_at DESC LIMIT 1 + If found and metadata satisfies requested_metadata -> mark consumed, return ALLOW. + +4. Return { allowed: false }. +``` + +`specificity_rank`: `execution-session=4 > channel-session=3 > task=2 > agent=1`. + +### 7.2 Decision endpoint + +``` +POST /api/permissions/requests/:id/decide +body: { + decision: 'once' | 'remember' | 'deny', + scopeOverride?: jsonb, // tighten metadata before remembering + expiresAt?: ISO8601, // for 'remember' + rememberSubject?: 'agent' | 'channel-session' | 'execution-session' | 'task', + // default: 'agent' if no channel context, else 'channel-session' + note?: string, +} +``` + +- `once` → `status='approved_once'`. The first matching `gate(...)` call consumes it (sets `consumed_at`, emits `permission_request_consumed` event). If `scopeOverride` is supplied it replaces `requested_metadata` so the consume step matches against the tightened scope. +- `remember` → `status='approved_durable'` + creates a row in `auth_permission_grants` (atomically, in one transaction). `durable_grant_id` is set. `scopeOverride` (if any) becomes the grant's `scope_metadata`. +- `deny` → `status='denied'`. Future calls return DENY immediately. + +### 7.3 Authorization for the decision + +A user can decide a request if: + +- They are workspace `owner` or `admin` of the request's tenant, **OR** +- They are the `ownerId` or `mentorId` of the requester bot, **OR** +- They are an `owner`/`admin` member of the `contextChannelId` (if present). + +This rule is encoded in `PermissionsService.canDecide(user, request)`, used by the controller and the WebSocket dispatcher. + +## 8. REST API + +All routes under `/api/permissions/*`, JWT-protected. + +``` +GET /grants?subjectKind=&subjectId=&permissionKey= +POST /grants # create proactive grant +DELETE /grants/:id # revoke (sets revoked_at) + +GET /requests?status=&scope=mine|tenant +GET /requests/by-spell/:spell # case-insensitive, normalized +POST /requests # bot creates (uses bot JWT) +DELETE /requests/:id # bot cancels (still pending) +POST /requests/:id/decide +POST /requests/by-spell/:spell/decide +``` + +Bots authenticate with the same JWT system as users (their shadow `im_users` row); the controller distinguishes via `userType='bot'`. + +## 9. WebSocket Events + +New domain `permissions` under `apps/server/libs/shared/src/events/domains/`. + +"Approvers" in the table below means **the set of users for whom `PermissionsService.canDecide(user, request)` returns true** — i.e., workspace owner/admin, bot `ownerId`/`mentorId`, and (when `contextChannelId` is set) channel `owner`/`admin` members. The exact rule is the one defined in §7.3. + +| Event | Payload | Recipients | +| ----------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------- | +| `permission_request_created` | `{ id, spellId, requesterBotId, permissionKey, requestedMetadata, reason, contextChannelId, expiresAt }` | All approvers for this request | +| `permission_request_decided` | `{ id, spellId, status, decidedByUserId, durableGrantId? }` | Approvers + the requester bot's connections | +| `permission_request_consumed` | `{ id, requesterBotId, key }` | Approvers (UI hides consumed) | +| `permission_grant_created` | `{ id, subjectKind, subjectId, permissionKey, scopeMetadata }` | Approvers | +| `permission_grant_revoked` | `{ id }` | Approvers | + +Events are emitted by `PermissionsService` after DB writes (single transaction commits, then publishes). + +## 10. Frontend + +### 10.1 Components + +``` +apps/client/src/components/permissions/ +├── PermissionInbox.tsx # list of pending requests, real-time +├── PermissionRequestCard.tsx # single request: spell id (copy button), reason, allow once / remember / deny +├── ScopeEditor.tsx # JSON-form editor driven by metadata schema (key-aware) +├── GrantList.tsx # per-subject grant table +└── GrantEditor.tsx # create/edit a grant for a chosen subject +``` + +Settings surfaces: + +- Agent settings page: "Permissions" tab → `` +- Channel settings → `` +- Routine detail page → `` + +Top-bar: bell icon shows badge with `pendingPermissionCount`; clicking opens `PermissionInbox`. + +In-channel UX: when a bot files a request whose `contextChannelId === currentChannel`, render an inline system-style message card embedding the request's spell id and the same approve/deny buttons (so users don't have to leave the chat). + +### 10.2 State + +- React Query hooks: `usePendingPermissionRequests()`, `useGrants(subject)`, `useDecidePermission()`, `useCreateGrant()`, `useRevokeGrant()`. +- Zustand `useAppStore` adds `pendingPermissionCount: number` driven by `permission_request_created` / `permission_request_decided` / `permission_request_consumed` events. + +### 10.3 i18n + +Strings under `apps/client/src/i18n/locales/{en,zh-CN}/permissions.json`. New domain. + +## 11. Agent Integration (claw-hive client) + +`packages/claw-hive/src/runtime/permissions-client.ts` exposes: + +```ts +export class PermissionsClient { + async ensure( + key: string, + metadata: object, + opts?: { + reason?: string; + waitMs?: number; // default 300_000 (5 min) + contextChannelId?: string; + contextExecutionId?: string; + contextRoutineId?: string; + }, + ): Promise< + | { allowed: true } + | { + allowed: false; + reason: "denied" | "timeout" | "expired"; + spellId?: string; + } + >; +} +``` + +Implementation: + +1. Call `gate(...)` via gateway. If allowed → return. +2. Else `POST /requests` with context. Receive `{ id, spellId }`. +3. (Optional) emit a synthetic system message in the bound channel announcing the spell id; UI renders the card. +4. Subscribe (via existing WS) for `permission_request_decided` matching `id`. +5. Resolve when decided or `waitMs` elapses. + +v1 does **not** wire this into agent tool calls automatically — the agent code calls `permissions.ensure(...)` explicitly. Auto-wrapping is a future enhancement. + +## 12. Audit & Observability + +- All grants and decisions are durable rows; no separate audit table needed for v1. +- Add Pino structured log on every grant/request/decision: `{ event, tenantId, actorUserId, requesterBotId, permissionKey, decision, durable }`. +- Posthog event `permission_decided` for analytics on approval rates by key. + +## 13. Edge Cases + +| Case | Behavior | +| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | +| Bot deleted while request pending | Cancel request via cascade (`requester_bot_id` is FK; `ON DELETE CASCADE` on the column). Decision endpoint returns 404 thereafter. | +| Channel archived while grant active | Grant remains; check still resolves to the (archived) channel id. UI marks as "archived context" but doesn't auto-revoke. | +| Routine execution completes before once-use approval consumed | Approval expires when execution row's `completedAt` is non-null; `gate(...)` ignores it. Add condition in lookup. | +| Two parallel `gate()` calls racing to consume same `approved_once` | `consumedAt` update uses `WHERE consumed_at IS NULL` and checks affected rows; loser falls through to DENY. | +| Spell-id collision under load | DB unique constraint + retry loop (max 5 attempts, escalating word count). | +| User decides after `expiresAt` | Endpoint returns 409 Conflict with current status `expired`. | +| Workspace/tenant deletion | Both tables cascade via `tenant_id` FK. | + +## 14. Testing Strategy + +(Adheres to Team9 100% coverage rule.) + +- **Unit:** + - `permission-matcher.spec.ts` — exhaustive table tests for absent / array / string / glob. + - `spell-id.service.spec.ts` — generation determinism via injectable RNG; collision retry; parse normalization. + - `permissions.service.spec.ts` — grant creation, revoke, gate algorithm with all subject kinds, once-use consume race. +- **Integration:** + - Gateway controller e2e against a Postgres testcontainer: full flow (proactive grant → gate allows; agent request → user decides once → second gate denies; remember → second gate allows). + - WebSocket event delivery to authorized recipients only. +- **Frontend:** + - Component tests for `PermissionRequestCard` (all three buttons, scope override). + - React Query hook tests with MSW. +- **Regression:** none required (greenfield). + +## 15. Rollout + +1. Phase 1 — schema + service + REST + WS, no UI consumers, no enforcement points. +2. Phase 2 — Frontend inbox + settings tabs. +3. Phase 3 — Wire `gate(...)` into one high-value enforcement point (e.g., bot tool invocation in `IM` module). Validate UX end-to-end. +4. Phase 4 — Add more enforcement points incrementally (wiki write, file ops, routine triggers). + +Each phase ships behind no feature flag (greenfield, additive); reverts are pure deletions. + +## 16. Open Questions (for user review) + +- **Q1:** Is `routine` (formerly task) really the "memo9-like entity" you meant? Or did you intend something like a _wiki page_ or _deliverable_? +- **Q2:** Should bot owners (`im_bots.ownerId` / `mentorId`) be allowed to decide requests, or only workspace admins? (Spec assumes both.) +- **Q3:** v1 enforcement scope — do you want me to wire `gate(...)` into a specific call site as part of the first PR, or land the framework alone first? +- **Q4:** Do you want a hard cap on grant `expiresAt` (e.g., max 90 days), or fully unbounded? +- **Q5:** Should the spell-id word list be exposed for branding (e.g., theme switcher: "crypto" / "fantasy" / "nature"), or fixed to crypto-themed v1? + +## 17. Files To Be Created / Modified (sketch) + +``` +NEW: + apps/server/libs/database/src/schemas/permissions/grants.ts + apps/server/libs/database/src/schemas/permissions/requests.ts + apps/server/libs/database/src/schemas/permissions/index.ts + apps/server/libs/database/migrations/00XX_permissions_init.sql + apps/server/apps/gateway/src/permissions/permissions.module.ts + apps/server/apps/gateway/src/permissions/permissions.service.ts + apps/server/apps/gateway/src/permissions/permissions.controller.ts + apps/server/apps/gateway/src/permissions/permission-matcher.ts + apps/server/apps/gateway/src/permissions/permission-keys.ts + apps/server/apps/gateway/src/permissions/spell-id.service.ts + apps/server/apps/gateway/src/permissions/spell-words.ts + apps/server/apps/gateway/src/permissions/dto/*.dto.ts + apps/server/apps/gateway/src/permissions/__tests__/*.spec.ts + apps/server/libs/shared/src/events/domains/permissions/index.ts + apps/client/src/components/permissions/{PermissionInbox,PermissionRequestCard,GrantList,GrantEditor,ScopeEditor}.tsx + apps/client/src/hooks/usePermissions.ts + apps/client/src/i18n/locales/{en,zh-CN}/permissions.json + packages/claw-hive/src/runtime/permissions-client.ts # team9-agent-pi monorepo + +MODIFIED: + apps/server/apps/gateway/src/app.module.ts # register PermissionsModule + apps/server/libs/database/src/schemas/index.ts # re-export permissions + apps/server/libs/shared/src/events/index.ts # add permissions domain + apps/client/src/services/websocket.ts # listeners + apps/client/src/stores/useAppStore.ts # pendingPermissionCount +``` From 8b1d1ddb89a358b0703092e24e6ae3a0aba72a56 Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 2 May 2026 15:01:38 +0800 Subject: [PATCH 02/23] docs: spec for message forwarding (single + multi-select bundle) 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) --- .../2026-05-02-message-forwarding-design.md | 482 ++++++++++++++++++ 1 file changed, 482 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-02-message-forwarding-design.md diff --git a/docs/superpowers/specs/2026-05-02-message-forwarding-design.md b/docs/superpowers/specs/2026-05-02-message-forwarding-design.md new file mode 100644 index 00000000..a230088f --- /dev/null +++ b/docs/superpowers/specs/2026-05-02-message-forwarding-design.md @@ -0,0 +1,482 @@ +# Message Forwarding (Single + Multi-Select Bundle) + +**Date:** 2026-05-02 +**Status:** Design — ready for plan +**Author:** Winrey + Claude +**Scope:** Team9 IM (gateway + im-worker + Tauri client) + +## 1. Goals + +Allow a user to forward an existing message — or a hand-picked group of messages — into another channel inside the same workspace, in a way that: + +1. Visibly marks the new message as "forwarded" (not authored by the forwarder). +2. For multi-select, packs N messages into a single bundle "chat record" message at the destination. +3. Carries enough source-context metadata that an **agent** can later locate the original channel and the position of the original message(s) — even if the source has been edited or soft-deleted. +4. Reuses the existing message pipeline (`createMessage` → `im_messages` → WS broadcast → outbox) instead of inventing a parallel path. + +### Non-goals (V1) + +- Multi-target send (one forward call → multiple destination channels at once). +- Cross-workspace forwarding. +- Cross-channel bundle (mixing sources from different channels into one bundle). +- Forward with a comment (附言). The forwarder may follow up with a separate normal message if they want to comment. +- Carrying reactions, read state, properties, or thread structure across the forward. +- Re-running AI auto-fill on the destination side based on forwarded content. +- Webhook / external surfacing (PostHog, search index updates beyond the standard message-created path). + +## 2. User-facing Behavior + +### 2.1 Entry points + +- **Hover toolbar** on a message gains a "Forward" icon (paper-plane). Click → opens the forward dialog with that single message preselected. +- **Hover toolbar** also gains a "Select" icon (checkmark). Click → enters channel-wide **selection mode**. +- **Right-click context menu** gains "Forward" and "Select" items mirroring the hover toolbar. +- **In selection mode**: + - A checkbox appears on the left of every message row in the channel. + - A floating action bar appears at the **bottom** of the channel pane, anchored above the message composer, showing `N selected · Forward · Cancel`. + - Clicking another message toggles its checkbox; `Shift+click` selects a range. + - `Esc` or `Cancel` exits selection mode without forwarding. + - Switching channels / opening another route auto-exits selection mode. + - Selection is constrained to the **same channel** — switching channels clears selection. +- Selection cap: **100 messages**. UI prevents adding the 101st with a toast (`forward.tooManySelected`). + +### 2.2 Forward dialog + +A modal with: + +- Title: `Forward message` / `Forward N messages`. +- Search box + scrollable list of channels in the current workspace where the user has **write access** (`assertWriteAccess`-equivalent). Channels are grouped: `Direct messages` / `Channels`. Archived / deactivated channels are excluded server-side and not shown. +- Single selection — clicking a channel highlights it. Confirming with `Forward` button sends. +- Below the channel list, a small **preview** panel shows what will be sent (single quote card, or bundle card with first 3 message previews + "…and N more"). +- No comment / 附言 input in V1. +- Loading state on confirm; close on success. +- Errors are shown inline (e.g. "You no longer have access to this channel"). + +### 2.3 Forwarded message rendering + +A new message of `type: 'forward'` renders with: + +- A subtle "Forwarded from #source-channel-name" header line above the body. +- **Single forward**: a quote card showing the original sender's avatar + display name + relative timestamp + content snapshot (Lexical-rendered when `contentAstSnapshot` is present, plaintext fallback otherwise) + attachment chips. +- **Bundle forward**: a stacked-paper card showing: + - Header: `Chat record · N messages from #source-channel-name`. + - Up to 3 preview rows: `@user · "first 80 chars of content…"`. + - Footer: `Click to view all` (when N > 3). + - Click anywhere → opens a **bundle viewer modal** with all N items rendered in their original order, each showing original sender + timestamp + full content (Lexical AST or HTML/Markdown fallback) + attachments. +- Both cards expose a `Jump to original` link **only when** the user still has read access to the source channel and the source message has not been hard-deleted. Otherwise the link is hidden and a small dimmed note `Source no longer available` is shown. +- Soft-deleted source: snapshot is rendered (it was captured at forward time); `Jump to original` is hidden. + +### 2.4 What can be forwarded + +Allowed source `messageType` values: `text`, `long_text`, `file`, `image`. + +Disallowed (silently filtered out of selection, with a tooltip on the disabled checkbox): + +- `system` — auto-generated context, semantically meaningless out of channel. +- `tracking` — tied to a tracking channel's structured data. +- `forward` itself **is allowed** — forwarding a forward is permitted; see §6.4. +- A message currently being streamed (`metadata.streaming === true` or the in-memory streaming message store says so). UI disables the checkbox. +- A message marked `isDeleted`. Already not visible. + +### 2.5 i18n + +All new strings go in `apps/client/src/i18n/locales/{en,zh-CN}/channel.json` under a `forward.*` namespace: + +``` +forward.toolbar.forward "Forward" / "转发" +forward.toolbar.select "Select" / "选择" +forward.contextMenu.forward "Forward" / "转发" +forward.contextMenu.select "Select" / "选择" +forward.dialog.titleSingle "Forward message" / "转发消息" +forward.dialog.titleBundle "Forward {{count}} messages" / "转发 {{count}} 条消息" +forward.dialog.searchPlaceholder "Search channels…" / "搜索频道…" +forward.dialog.confirm "Forward" / "发送" +forward.dialog.cancel "Cancel" / "取消" +forward.selection.bar "{{count}} selected" / "已选 {{count}} 条" +forward.selection.cancel "Cancel" / "取消" +forward.tooManySelected "You can forward up to 100 messages at once." / "一次最多转发 100 条消息" +forward.card.fromChannel "Forwarded from #{{channelName}}" / "转自 #{{channelName}}" +forward.bundle.title "Chat record · {{count}} messages" / "聊天记录 · {{count}} 条" +forward.bundle.viewAll "View all" / "查看全部" +forward.bundle.modalTitle "Chat record from #{{channelName}}" / "来自 #{{channelName}} 的聊天记录" +forward.source.unavailable "Source no longer available" / "原消息已不可访问" +forward.source.jumpTo "Jump to original" / "跳转到原消息" +forward.error.notAllowed "This message can't be forwarded." / "此消息不可转发" +forward.error.noWriteAccess "You can't forward to this channel." / "你没有该频道的发送权限" +forward.error.mixedChannels "All selected messages must come from the same channel." / "多选转发的消息必须来自同一频道" +``` + +Existing locale files are merged additively — no key removal. + +## 3. Data Model + +### 3.1 Enum extension + +`apps/server/libs/database/src/schemas/im/messages.ts` — add `'forward'` to `messageTypeEnum`: + +```ts +export const messageTypeEnum = pgEnum("message_type", [ + "text", + "file", + "image", + "system", + "tracking", + "long_text", + "forward", +]); +``` + +Client mirror: `apps/client/src/types/im.ts` `MessageType` union adds `'forward'`. + +### 3.2 New table — `im_message_forwards` + +```ts +// apps/server/libs/database/src/schemas/im/message-forwards.ts +import { + pgTable, + uuid, + integer, + timestamp, + jsonb, + varchar, + bigint, + index, +} from "drizzle-orm/pg-core"; +import { messages } from "./messages.js"; +import { channels } from "./channels.js"; +import { tenants } from "../tenant/tenants.js"; +import { users } from "./users.js"; + +export interface ForwardAttachmentSnapshot { + originalAttachmentId: string; // id of the source im_message_attachments row at forward time + fileName: string; + fileUrl: string; + fileKey: string | null; + fileSize: number; + mimeType: string; + thumbnailUrl: string | null; + width: number | null; + height: number | null; +} + +export const messageForwards = pgTable( + "im_message_forwards", + { + id: uuid("id").primaryKey().defaultRandom(), + + // The newly created forward-type message that holds this row(s). + forwardedMessageId: uuid("forwarded_message_id") + .references(() => messages.id, { onDelete: "cascade" }) + .notNull(), + + // Order inside a bundle (0 for single-forward, 0..N-1 for bundle). + position: integer("position").notNull(), + + // Pointer to the original message. Set null if the source row is hard-deleted + // (we keep snapshot fields intact so rendering still works). + sourceMessageId: uuid("source_message_id").references(() => messages.id, { + onDelete: "set null", + }), + + // Denormalized source location — survives source deletion. Used by agents to + // locate the original channel + workspace + sender + position. + // FK action is RESTRICT (default) because the column is NOT NULL and + // channels are soft-archived in this product, never hard-deleted. + sourceChannelId: uuid("source_channel_id") + .references(() => channels.id) + .notNull(), + sourceWorkspaceId: uuid("source_workspace_id").references( + () => tenants.id, + { + onDelete: "set null", + }, + ), + sourceSenderId: uuid("source_sender_id").references(() => users.id, { + onDelete: "set null", + }), + sourceCreatedAt: timestamp("source_created_at").notNull(), + sourceSeqId: bigint("source_seq_id", { mode: "bigint" }), + + // Snapshot at forward time — guarantees we can render even if original is + // edited or hard-deleted. AST is the canonical render path; plaintext is + // search/preview fallback. Attachments are stored ONLY here (the forward + // message itself has no rows in im_message_attachments) — see Notes #5. + contentSnapshot: varchar("content_snapshot", { length: 100_000 }), + contentAstSnapshot: jsonb("content_ast_snapshot").$type< + Record + >(), + attachmentsSnapshot: jsonb("attachments_snapshot").$type< + ForwardAttachmentSnapshot[] + >(), + sourceType: varchar("source_type", { length: 32 }).notNull(), + + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [ + index("idx_mf_forwarded").on(table.forwardedMessageId), + index("idx_mf_source_msg").on(table.sourceMessageId), + index("idx_mf_source_channel").on(table.sourceChannelId), + index("idx_mf_source_workspace").on(table.sourceWorkspaceId), + ], +); + +export type MessageForward = typeof messageForwards.$inferSelect; +export type NewMessageForward = typeof messageForwards.$inferInsert; +``` + +Notes: + +1. One forward = exactly 1 row for `kind: 'single'`, exactly N rows for `kind: 'bundle'` (sharing the same `forwardedMessageId`, `position` 0..N-1). +2. `sourceChannelId` is `NOT NULL` so the agent-traceability invariant always holds. The FK uses default action (`NO ACTION` / `RESTRICT`) — channels in this product are soft-archived, never hard-deleted (verified against `channels.ts` — no destructive cascade configured). If a future migration starts hard-deleting channels, this column would need to become nullable. +3. `contentSnapshot` capped at 100 000 chars (matches `long_text` upper bound + headroom). Anything longer is truncated server-side at forward time and a `truncated: true` flag is added both to that row and to `metadata.forward`. +4. `sourceType` records what the original `messageType` was at forward time (so the renderer knows whether to draw an image preview / file row / text body / nested forward). +5. **Attachments are stored only inside `attachmentsSnapshot` JSON.** The forward message itself has zero rows in `im_message_attachments`. Reasons: (a) avoids "ghost" attachment rows pointing at a forward message they don't logically belong to; (b) bundle forwards aggregate N items so per-position attachment ownership cannot be expressed via the existing `im_message_attachments(messageId)` shape; (c) the underlying S3 / file-keeper blob is referenced by the same `fileUrl` / `fileKey` snapshot, so download still works without a row duplication. The forward renderer reads attachments from the snapshot instead of via `getMessageAttachments`. + +### 3.3 Forwarded-message metadata + +The new top-level forward message stores in `messages.metadata.forward`: + +```ts +metadata.forward = { + kind: 'single' | 'bundle', + count: number, // 1 for single, N for bundle + sourceChannelId: string, // duplicated from rows for cheap reads + sourceChannelName: string, // snapshot for header rendering when source channel is renamed/inaccessible + truncated?: boolean, // any item snapshot was truncated +}; +``` + +`messages.content` for a forward message is set to a plaintext digest: + +- Single: `[Forwarded] {original sender display name}: {first 200 chars of content}`. +- Bundle: `[Forwarded chat record · N messages from #channel] {first sender}: {first 80 chars}; …`. + +This keeps preview / notification / search-index paths working without needing forward-specific code in those layers. + +`messages.contentAst` is `null` for forward messages. Renderer dispatches on `type === 'forward'` and reads the relational rows. + +### 3.4 Migration + +Generated via `pnpm db:generate`. Migration file lives under `apps/server/libs/database/migrations/` following the existing numbering. Migration is forward-only (we do not write a `down`); rollback strategy is restore-from-backup as per existing project convention. + +## 4. Backend API + +### 4.1 New endpoint + +`POST /api/v1/im/channels/:targetChannelId/forward` + +Request body: + +```ts +{ + sourceChannelId: string; // must equal channel of every sourceMessageId + sourceMessageIds: string[]; // 1..100 + clientMsgId?: string; // dedup key for the new forward message +} +``` + +Response: `MessageResponse` (the new forward-type message, in standard preview-truncated form). + +Behavior: + +1. `assertReadAccess(sourceChannelId, userId)` — must be able to read the source. +2. `assertWriteAccess(targetChannelId, userId)` — equivalent of `isMember` + `!isArchived` + `!isDeactivated` + bot-DM policy. (Reuse the same checks `MessagesController.createChannelMessage` runs today; extract to `channelsService.assertWriteAccess` if not already public — see §4.4.) +3. Validate `sourceMessageIds.length` is `1..100`. Reject `0` (`BadRequest`) and `>100` (`BadRequest forward.tooManySelected`). +4. Load all source messages in one query. Validate: + - Each exists, is not `isDeleted`. + - Each `channelId === sourceChannelId` (`BadRequest forward.error.mixedChannels`). + - Each `type ∈ {text, long_text, file, image, forward}` (`BadRequest forward.error.notAllowed`). + - None has `metadata.streaming === true` (`BadRequest forward.error.notAllowed`). + - Re-forward case (`type === 'forward'`): allowed; we **do not flatten** the chain. The new forward row's `sourceMessageId` points at the previous forward message; the snapshot is captured from that forward message's plaintext digest + its first attachment set. Agents can walk back one hop via the relational table; deeper chains require deeper queries. +5. Load attachments for all source messages (read-only; we are snapshotting, not duplicating). +6. Decide `kind`: `'single'` if `length === 1`, else `'bundle'`. +7. Build the new forward message via the existing `createMessage` gRPC path (reuses outbox, dedup, seq-id). Pass `type: 'forward'`, `metadata.forward`, computed plaintext `content`, and an empty attachments list (forward messages own no `im_message_attachments` rows — see §3.2 Note 5). +8. After the new message is persisted, insert N `im_message_forwards` rows in one batch insert. If this insert fails, the new forward message is soft-deleted (`isDeleted = true`) by the controller's catch handler and a 500 is surfaced. Risk is bounded: forward-row insert is a simple multi-row INSERT with no FK back-pressure aside from the freshly created `forwardedMessageId`. +9. Broadcast via `WS_EVENTS.MESSAGE.NEW` (no new event type needed — client renders by `type`). +10. Skip `triggerAiAutoFill` for forwarded messages (gated on `type !== 'forward'`). +11. Skip `RABBITMQ_ROUTING_KEYS.MESSAGE_CREATED` for `type === 'forward'` so we don't trigger agent reactions on forwarded chatter. +12. Emit a search-index event `message.created` as today, with `content` = the digest. + +### 4.2 Read path changes + +`MessagesService.getMessageWithDetails` (and the bulk equivalents used by `getChannelMessages` / `getThread`) — when assembling a `MessageResponse`, if `type === 'forward'`, also load: + +- All `im_message_forwards` rows for that message (`forwardedMessageId = $`), ordered by `position ASC`. +- For each row, hydrate a `ForwardItemResponse`: + ```ts + { + position: number; + sourceMessageId: string | null; + sourceChannelId: string; + sourceChannelName: string | null; // resolved at read time, may be null if user lacks access + sourceWorkspaceId: string | null; + sourceSender: { id, username, displayName, avatarUrl } | null; // null if user deleted + sourceCreatedAt: string; + sourceSeqId: string | null; + sourceType: 'text' | 'long_text' | 'file' | 'image' | 'forward'; + contentSnapshot: string | null; + contentAstSnapshot: unknown | null; + attachmentsSnapshot: ForwardAttachmentSnapshot[]; + canJumpToOriginal: boolean; // true iff sourceMessageId still exists AND user has read access to sourceChannelId + truncated: boolean; + } + ``` +- `sourceChannelName` is resolved per-request via the existing channel-name cache (Redis-backed). On miss → DB lookup. +- `canJumpToOriginal` is computed by batching `assertReadAccess` checks across all distinct source channels in the page (one membership check per channel, not per row). +- The `MessageResponse` adds an optional `forward?: { kind, count, sourceChannelId, sourceChannelName, items: ForwardItemResponse[], truncated }` field. Existing consumers that ignore unknown fields are unaffected. + +### 4.3 Truncation + +`MessagesService.truncateForPreview` — for `type === 'forward'`, do **not** truncate the relational items (already snapshot-shaped); only truncate the top-level `content` digest as today. The bundle viewer modal calls a separate full endpoint: + +`GET /api/v1/im/messages/:id/forward-items` → returns the full untruncated `ForwardItemResponse[]`. + +(For the channel list view we send only the truncated digest + first 3 items; bundle viewer fetches the full set on open. This keeps the channel scroll payload bounded.) + +### 4.4 Channel access helper + +`ChannelsService.assertReadAccess` exists. We add a peer: + +```ts +async assertWriteAccess(channelId: string, userId: string): Promise +``` + +Implementation lifts the existing checks scattered in `createChannelMessage`: + +- `isMember` +- channel `!isArchived` +- reject when `channel.isActivated === false` (tracking / one-shot channels that have been deactivated) +- bot-DM outbound policy (mirrors `assertMentionsAllowed`-style restrictions if applicable) + +Existing call sites in `MessagesController.createChannelMessage` are refactored to call this helper (small refactor, in scope per "improve code we're working in"). + +### 4.5 Error matrix + +| Condition | HTTP | Error code | +| --------------------------------------------- | ------------------------- | ------------------------- | +| User not a member of source | 403 | `forward.noSourceAccess` | +| User not a member of target / target archived | 403 | `forward.noWriteAccess` | +| Empty `sourceMessageIds` | 400 | `forward.empty` | +| `sourceMessageIds.length > 100` | 400 | `forward.tooManySelected` | +| Mixed source channels | 400 | `forward.mixedChannels` | +| Disallowed source type / streaming / deleted | 400 | `forward.notAllowed` | +| Source message not found | 404 | `forward.notFound` | +| Self-forward to same channel | allowed (no special-case) | — | + +## 5. WebSocket + +No new event names. The new forward message is broadcast via the existing `WS_EVENTS.MESSAGE.NEW` payload. Clients render by `type === 'forward'`. + +If a forwarded message is later edited (allowed: only `messages.content`/digest can be edited — but in V1 we **disallow edits on forward messages**; controller rejects `PATCH /messages/:id` with 400 `forward.editDisabled` when target is type forward) or deleted, existing `MESSAGE.UPDATED` / `MESSAGE.DELETED` events fire as today. + +## 6. Frontend + +### 6.1 New components + +- `apps/client/src/components/channel/forward/` + - `ForwardDialog.tsx` — modal, channel picker, preview pane, confirm. + - `ForwardChannelList.tsx` — scrollable list with search; data via `useChannels()` (existing hook) filtered to write-accessible. + - `ForwardPreviewSingle.tsx` — single-quote preview. + - `ForwardPreviewBundle.tsx` — bundle preview (first 3). + - `ForwardedMessageCard.tsx` — replaces normal message body when `message.type === 'forward'`. Branches into single vs bundle. + - `ForwardBundleViewer.tsx` — modal listing all bundle items; opens on bundle-card click. + - `forward-selection-store.ts` — Zustand store (matches existing `useAppStore` / `useWorkspaceStore` pattern) holding `{ active: boolean, channelId: string | null, selectedIds: Set }`. Only ever active for one channel at a time. + - `__tests__/` — unit tests per component. + +### 6.2 Modifications + +- `MessageHoverToolbar.tsx` — add Forward + Select icons (ordered after Reply, before Copy). +- `MessageContextMenu.tsx` — add `forward` and `select` items. +- `MessageItem.tsx` — when selection mode is active and the row is in the active selection-mode channel, render a left-side checkbox; clicking the row toggles selection (block existing actions like opening thread). Disable checkbox for ineligible types with tooltip. +- `MessageList.tsx` — when selection mode is active for this channel, render a sticky bottom action bar with `N selected · Forward · Cancel`. Listen for route changes to clear selection. +- `MessageContent.tsx` — when `message.type === 'forward'`, render `` instead of normal content. +- `apps/client/src/services/api.ts` — add `forwardMessages({ targetChannelId, sourceChannelId, sourceMessageIds })` and `getForwardItems(messageId)`. +- `apps/client/src/types/im.ts` — `MessageType` adds `'forward'`; `Message` adds optional `forward?: ForwardPayload`. +- `apps/client/src/stores/` — selection store registered. +- WS handler in `apps/client/src/services/websocket.ts` — no change (existing `new_message` listener handles it; React Query cache invalidation is already keyed by channelId). + +### 6.3 Keyboard + +- In selection mode: `Esc` cancels; `Enter` opens the forward dialog if `selectedIds.size >= 1`; `Shift+click` extends. +- In hover toolbar: `F` shortcut wired in `MessageContextMenu` shortcuts (matches existing T/L/E pattern). + +### 6.4 Re-forward chain rendering + +Forwarding a forward yields a forward message whose snapshot is just the previous forward's plaintext digest + the first attachment set, **not** a recursively expanded chain. The card shows `Forwarded from #X` per usual; `Jump to original` (if accessible) lands the user on the previous forward message. Walking deeper is the user's responsibility (one hop at a time). This keeps storage bounded and avoids infinite-recursion edge cases. + +## 7. Agent Integration + +The `im_message_forwards` table is the contract. Agents (and the search/index layer if it ever needs it) can: + +- `WHERE source_message_id = ?` — find every forward of a specific message. +- `WHERE source_channel_id = ?` — find every forward whose source is in a given channel. +- `WHERE forwarded_message_id = ?` — load all source pointers for one forward message. +- Join `forwarded_message_id` back to `im_messages` to find the destination channel + sender + time. + +The denormalized `sourceChannelId`, `sourceWorkspaceId`, `sourceSenderId`, `sourceCreatedAt`, `sourceSeqId` are explicitly there so agents can locate the original conversation **position** even if the original message row is gone (`sourceMessageId` is null) — they can fetch a slice of `im_messages` around `sourceCreatedAt` / `sourceSeqId` in `sourceChannelId`. + +No new MCP tool, no new gRPC method in V1. If agents need a higher-level "trace this forward back to its origin" tool later, it goes on top of these columns. + +## 8. Permissions Recap + +- **Forward action**: requires read on source channel + write on target channel. +- **Bundle viewer modal**: any user who can read the forward message can view the snapshot. They do NOT need access to the source channel. +- **Jump to original**: requires read access to source channel at click time (re-checked server-side via `assertReadAccess`; otherwise 403 → UI shows "Source no longer available"). +- **Edit / delete forward message**: edit disabled (V1). Delete allowed for own message + admins/owners (matches existing message delete policy). + +## 9. Testing + +Per project CLAUDE.md (100% coverage on new code). + +### 9.1 Backend unit + +- `messages.service.spec.ts`: + - `forward()` happy path — single text, single image, single file, single long_text. + - `forward()` happy path — bundle of 5 mixed types. + - Mixed-channel rejection. + - Disallowed type rejection (system, tracking, streaming, deleted). + - Empty / >100 selection rejection. + - Re-forward chain — snapshot capture from a previous forward message. + - Truncation flag set when content > 100k. + - Attachment snapshot capture: `attachmentsSnapshot` arrays carry `originalAttachmentId` + identical `fileUrl` / `fileKey`; no rows are added to `im_message_attachments` for the forward message. + - Race: source deleted between read and insert → either succeeds with snapshot or returns 404 deterministically. +- `channels.service.spec.ts`: + - `assertWriteAccess` happy + each rejection branch (not member, archived, deactivated, bot-DM blocked). +- New `message-forwards.spec.ts`: + - Schema-level: `position` ordering, cascade on `forwardedMessageId` delete, `set null` on `sourceMessageId` delete. + +### 9.2 Backend e2e (gateway) + +`forward.e2e-spec.ts` (under existing test harness): + +- Create channels A, B; user member of both; post 3 messages in A; forward all 3 to B; assert WS broadcast to B subscribers; assert `GET /messages/:id` on the new forward returns hydrated `forward.items` length 3 with correct positions; assert `GET /forward-items` returns same. +- Forward a single image; assert no row is created in `im_message_attachments` for the new forward message; assert `attachmentsSnapshot[0].fileUrl` matches the original; assert downloading via that URL succeeds. +- Forward across channels you have read but not write on target → 403. +- Forward from channel you can't read → 403. +- Re-forward the resulting forward into channel C; assert chain depth 1 (no recursive expansion). +- Source message hard-deleted later: `forward-items` still renders snapshot, `canJumpToOriginal === false`. + +### 9.3 Frontend unit + +Each new component gets `__tests__/Component.test.tsx`: + +- `ForwardDialog` — opens preselected, channel filter, confirm calls API, error toast on failure. +- `ForwardChannelList` — search filter, write-access filter, archived hidden. +- `ForwardedMessageCard` — single vs bundle branch, jump link visibility honors `canJumpToOriginal`, source-unavailable note. +- `ForwardBundleViewer` — fetches items lazily on open, handles 404, renders all positions in order. +- `forward-selection-store` — selection enter/exit, cap enforcement, channel-switch clears. +- `MessageContextMenu` — new items appear with right callbacks. +- `MessageHoverToolbar` — new icons appear, click handlers wired. +- `MessageList` — selection bar appears at the right time, Esc cancels. + +### 9.4 Coverage gates + +`pnpm --filter @team9/server test:cov` and `pnpm --filter @team9/client test:cov` must show 100% line + branch coverage for the new files. Files needing `coverage ignore` must be confirmed with the user (none expected). + +## 10. Open Questions / Future Work + +- **Multi-target send** — deferred. Adds N forward messages per call; trivial extension once V1 lands (loop the API on the client; or accept `targetChannelIds: string[]` server-side). Current shape is forward-compatible. +- **附言 (comment)** — deferred. Easiest extension: optional `comment` field on the request that becomes a separate sibling message immediately following the forward, posted in the same transaction. Or, change `messages.content` of the forward message itself to be the user's comment with the digest pushed to `metadata`. Decide at extension time. +- **Bundle viewer pagination** — V1 caps at 100 items, no pagination needed. If we ever raise the cap, paginate `GET /forward-items?cursor=`. +- **Permission decay** — if user A forwards channel X content to channel Y, and later user A is removed from X, the forward message in Y still shows the snapshot. This is intentional: snapshots are point-in-time and the user already had access when forwarding. Document in the UI footer of the bundle viewer if user feedback flags it. +- **Audit log** — should forwarding be recorded in `im_audit_logs`? Currently no — neither `pin`/`copy`/`reaction` are. If compliance later needs it, add a single audit row on `forward` per call (target + source ids). From 884a17d5bfc0ded17720a70b8544bcd598a0db6c Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 2 May 2026 15:07:06 +0800 Subject: [PATCH 03/23] docs(spec): resolve open questions on permissions design - 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) --- ...-05-02-permissions-and-approvals-design.md | 177 ++++++++++++++---- 1 file changed, 141 insertions(+), 36 deletions(-) diff --git a/docs/superpowers/specs/2026-05-02-permissions-and-approvals-design.md b/docs/superpowers/specs/2026-05-02-permissions-and-approvals-design.md index c39893f0..5acdcd91 100644 --- a/docs/superpowers/specs/2026-05-02-permissions-and-approvals-design.md +++ b/docs/superpowers/specs/2026-05-02-permissions-and-approvals-design.md @@ -1,7 +1,7 @@ # Permissions and Approvals System — Design **Date:** 2026-05-02 -**Status:** Draft (pending user approval) +**Status:** Reviewed (open questions resolved 2026-05-02) **Author:** Claude (auto-mode brainstorming) ## 1. Background @@ -46,24 +46,95 @@ Out of scope for v1: claw-hive runtime tool-call hooks (will be added in a follo > **Note on terminology:** the user's request uses "session" generically; we split it into chat-session vs execution-session because they have different lifetimes and audit semantics. -## 4. Permission Keys & Metadata Schema +## 4. Permission Keys, Scope Schema & Approver Resolution -Permission keys are **defined in code** (not a DB table), with each key declaring a JSONSchema for valid `scope_metadata`. v1 ships a small set; new keys are added by adding a registry entry. +Permission keys are **defined in code** (not a DB table). Each key is registered with: + +- a JSONSchema for the valid `scope_metadata` shape, +- a `resolveApprovers(ctx)` function returning the **resource holders** for a given request context (the primary approvers), +- an optional `defaultApprovers` fallback when the resolver returns an empty set, +- a `risk` label. + +This is how Q2 ("primary approver = resource holder") is encoded: the key itself owns the rule for who is allowed to grant it. Adding a new permission means writing both the scope shape and a holder-resolver in the same file, so the two can never drift. ```ts // apps/server/apps/gateway/src/permissions/permission-keys.ts -export const PERMISSION_KEYS = { - "messages:send": { metadata: ChannelScopeSchema, risk: "low" }, - "messages:read": { metadata: ChannelScopeSchema, risk: "low" }, - "tools:invoke": { metadata: ToolScopeSchema, risk: "medium" }, - "wiki:read": { metadata: WikiScopeSchema, risk: "low" }, - "wiki:write": { metadata: WikiScopeSchema, risk: "high" }, - "files:read": { metadata: PathScopeSchema, risk: "medium" }, - "files:write": { metadata: PathScopeSchema, risk: "high" }, - "routine:trigger": { metadata: RoutineScopeSchema, risk: "medium" }, +export interface PermissionKeyDef { + metadata: JSONSchema; // shape of scope_metadata + risk: "low" | "medium" | "high"; + resolveApprovers: ( + ctx: ApproverContext, + deps: ApproverDeps, + ) => Promise; + defaultApprovers?: "workspace-admins" | "bot-owners" | "none"; + describe: (metadata: object) => string; // human-readable for UI +} + +export const PERMISSION_KEYS: Record = { + "messages:send": { + metadata: ChannelScopeSchema, + risk: "low", + resolveApprovers: async ({ metadata, contextChannelId }, { db }) => { + const channelId = pickChannelId(metadata, contextChannelId); + return channelId ? db.findChannelOwnersAndAdmins(channelId) : []; + }, + defaultApprovers: "workspace-admins", + describe: (m) => + `Send messages${m.channelIds ? ` in ${m.channelIds.length} channel(s)` : ""}`, + }, + "messages:read": { + /* similar */ + }, + "tools:invoke": { + metadata: ToolScopeSchema, + risk: "medium", + resolveApprovers: async ({ requesterBotId }, { db }) => + db.findBotOwnerAndMentor(requesterBotId), + defaultApprovers: "workspace-admins", + describe: (m) => + `Invoke tool${m.toolNames ? ` (${m.toolNames.join(", ")})` : ""}`, + }, + "wiki:read": { + metadata: WikiScopeSchema, + risk: "low", + resolveApprovers: async ({ metadata }, { db }) => + db.findWikiOwners(metadata.wikiId), + defaultApprovers: "workspace-admins", + describe: (m) => `Read wiki${m.wikiId ? ` ${m.wikiId}` : ""}`, + }, + "wiki:write": { + /* same shape, risk: 'high' */ + }, + "files:read": { + /* PathScopeSchema, holder = file-keeper resource owner */ + }, + "files:write": { + /* same, high risk */ + }, + "routine:trigger": { + metadata: RoutineScopeSchema, + risk: "medium", + resolveApprovers: async ({ metadata }, { db }) => + db.findRoutineCreatorAndOwner(metadata.routineId), + defaultApprovers: "workspace-admins", + describe: (m) => `Trigger routine ${m.routineId}`, + }, } as const; ``` +**Approver resolution order** (used by `PermissionsService.resolveApprovers(request)`): + +1. Run `key.resolveApprovers(ctx)` → primary holders. +2. Union with `request.suggestedApproverIds` (an optional array the AI can include when filing the request — see §8). +3. If the union is empty, apply `key.defaultApprovers` (`workspace-admins` or `bot-owners`). +4. Workspace `owner` always belongs to the approver set as a safety net (cannot be excluded). + +This is the **only** place that decides who can decide a request. The WS dispatcher (§9) and the controller's `canDecide(...)` check both call it. + +**`ApproverContext`** carries `tenantId`, `requesterBotId`, `permissionKey`, `metadata`, `contextChannelId?`, `contextExecutionId?`, `contextRoutineId?` — the same data the request row holds. + +**`ApproverDeps`** is a small interface (`db.findChannelOwnersAndAdmins(...)`, `db.findBotOwnerAndMentor(...)`, etc.) implemented by `PermissionsApproverRepository`. Centralizing the queries keeps key definitions declarative and makes resolvers trivially mockable in tests. + Scope schemas are intersection-style: each property is optional; presence narrows scope; absence means unrestricted. ```jsonc @@ -168,6 +239,9 @@ export const authPermissionRequests = pgTable( requestedMetadata: jsonb("requested_metadata") .$type>() .default({}), + suggestedApproverIds: uuid("suggested_approver_ids") + .array() + .default(sql`ARRAY[]::uuid[]`), // optional: AI-supplied extra approvers, validated server-side reason: text("reason"), status: requestStatusEnum("status").notNull().default("pending"), decidedByUserId: uuid("decided_by_user_id").references(() => imUsers.id), @@ -218,11 +292,17 @@ export class SpellIdService { } ``` -**Word list:** ~200 lowercase blockchain/crypto-themed words (3-7 chars), kept in `spell-words.ts`. Curated for memorability and avoidance of homophones. Examples: `ledger`, `shard`, `hash`, `mint`, `forge`, `crystal`, `flame`, `raven`, `storm`, `sigil`, `oracle`, `beacon`, `zenith`, `ember`, `cipher`, `vault`, `chain`, `block`, `node`, `gas`, `key`, `seal`, `rune`, `glyph`, `prism`, `ether`, `omen`, `relic`. +**Word list:** the **BIP-39 English mnemonic word list** (the same list used by crypto wallets for recovery phrases, also commonly called "secret words"). 2048 words, 3–8 lowercase letters each, designed so the first four letters of every word are unique → unambiguous when typed or spoken aloud. + +Stored as a static asset at `apps/server/apps/gateway/src/permissions/spell-words.ts` (re-exported from a checked-in copy of the BIP-39 list, ~13 KB). No runtime dependency on a wallet library — the file is a `readonly string[]`. -**Collision:** `generate()` calls until a unique row insert succeeds (DB unique constraint is the source of truth). Bumps `wordCount` from 3 → 4 → 5 if needed. +**Combinatorics:** with 3 words → 2048³ ≈ 8.6 billion combinations; collisions for any realistic pending-set size are negligible. 4-word fallback exists only as defense-in-depth. -**Format:** lowercase letters + single spaces; regex `^[a-z]+( [a-z]+){2,4}$`. The Spell ID is **not** a security secret — it's a memorable handle. Authentication still goes through normal JWT. +**Collision handling:** `generate()` retries until the DB unique constraint accepts the insert. After 3 retries at 3 words, escalates to 4 words. + +**Format:** lowercase letters + single spaces; regex `^[a-z]+( [a-z]+){2,4}$`. Parser normalizes (trim, lowercase, collapse runs of whitespace) so users can type it loosely. + +**Spell ID is not a secret.** Authentication is still JWT; the spell id is purely a memorable handle for "which request are we deciding right now," especially useful when the user reads it aloud from a notification or pastes it into chat. ## 7. Decision & Check Algorithms @@ -281,15 +361,29 @@ body: { - `remember` → `status='approved_durable'` + creates a row in `auth_permission_grants` (atomically, in one transaction). `durable_grant_id` is set. `scopeOverride` (if any) becomes the grant's `scope_metadata`. - `deny` → `status='denied'`. Future calls return DENY immediately. -### 7.3 Authorization for the decision +### 7.3 Approver resolution & decision authorization + +There is **one** function that decides who may decide a request, and it lives in `PermissionsService.resolveApprovers(request)`. The algorithm: -A user can decide a request if: +``` +approvers := key.resolveApprovers({ + tenantId, requesterBotId, permissionKey, + metadata: request.requested_metadata, + contextChannelId, contextExecutionId, contextRoutineId, + }) +if request.suggested_approver_ids?.length: + approvers ∪= validate(suggested_approver_ids) // must be in same tenant +if approvers is empty: + approvers := fallback(key.defaultApprovers) // workspace-admins / bot-owners / none +approvers ∪= workspace_owners(tenant) // safety-net, never excluded +return approvers +``` -- They are workspace `owner` or `admin` of the request's tenant, **OR** -- They are the `ownerId` or `mentorId` of the requester bot, **OR** -- They are an `owner`/`admin` member of the `contextChannelId` (if present). +`PermissionsService.canDecide(user, request)` returns `true` iff `user.id ∈ resolveApprovers(request)`. -This rule is encoded in `PermissionsService.canDecide(user, request)`, used by the controller and the WebSocket dispatcher. +Both the controller (`POST /requests/:id/decide`) and the WebSocket dispatcher (§9) ask this single function — there is no second authorization rule anywhere else. + +**Validation of suggested approvers:** the bot can suggest only users that exist in the same tenant. Suggestions that fail validation are dropped silently (logged), they don't reject the request — the holder set still applies. ## 8. REST API @@ -300,9 +394,10 @@ GET /grants?subjectKind=&subjectId=&permissionKey= POST /grants # create proactive grant DELETE /grants/:id # revoke (sets revoked_at) -GET /requests?status=&scope=mine|tenant +GET /requests?status=&scope=mine|tenant # 'mine' = approver list contains caller GET /requests/by-spell/:spell # case-insensitive, normalized POST /requests # bot creates (uses bot JWT) + # body MAY include suggestedApproverIds: uuid[] DELETE /requests/:id # bot cancels (still pending) POST /requests/:id/decide POST /requests/by-spell/:spell/decide @@ -314,7 +409,7 @@ Bots authenticate with the same JWT system as users (their shadow `im_users` row New domain `permissions` under `apps/server/libs/shared/src/events/domains/`. -"Approvers" in the table below means **the set of users for whom `PermissionsService.canDecide(user, request)` returns true** — i.e., workspace owner/admin, bot `ownerId`/`mentorId`, and (when `contextChannelId` is set) channel `owner`/`admin` members. The exact rule is the one defined in §7.3. +"Approvers" in the table below means the set returned by `PermissionsService.resolveApprovers(request)` (§7.3) — i.e., the per-key resource holders, plus AI-suggested approvers, plus workspace owners as a safety net. | Event | Payload | Recipients | | ----------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------- | @@ -431,20 +526,28 @@ v1 does **not** wire this into agent tool calls automatically — the agent code ## 15. Rollout -1. Phase 1 — schema + service + REST + WS, no UI consumers, no enforcement points. -2. Phase 2 — Frontend inbox + settings tabs. -3. Phase 3 — Wire `gate(...)` into one high-value enforcement point (e.g., bot tool invocation in `IM` module). Validate UX end-to-end. -4. Phase 4 — Add more enforcement points incrementally (wiki write, file ops, routine triggers). +The first PR ships everything needed to demonstrate and test the loop end-to-end: + +1. **PR 1 (this spec)** — DB schema + `PermissionsService` (gate / grant / request / decide / consume) + per-key resolvers for `messages:send`, `tools:invoke`, `routine:trigger` + REST + WS events + frontend inbox & settings tabs + **one concrete enforcement point** (see below). +2. **PR 2+** — Add enforcement at additional call sites (wiki write, file-keeper ops, more routine actions) and the claw-hive auto-wrapper for tool calls. + +**First enforcement point (PR 1):** `messages:send` for **bot cross-channel posts** — when a bot calls the message-create flow targeting a channel where it is not a member, the IM service calls `permissions.gate('messages:send', { channelId }, ctx)`. If denied, the IM service files a permission request (with the channel's owners/admins as the resolved approver set) and returns a `PERMISSION_PENDING` error to the bot containing `{ requestId, spellId }`. On approval, the bot retries. + +Why this first: + +- Fully server-enforced — testable from gateway integration tests without claw-hive in the loop. +- Smallest blast radius — only fires on the rare cross-channel post path; existing in-channel sends are unaffected. +- Demonstrates every system component: holder resolution (channel owners), spell-id propagation, in-channel approval card UX, durable-grant retry path. -Each phase ships behind no feature flag (greenfield, additive); reverts are pure deletions. +No feature flag — additive only. A revert is purely a deletion. -## 16. Open Questions (for user review) +## 16. Resolved Decisions (from 2026-05-02 review) -- **Q1:** Is `routine` (formerly task) really the "memo9-like entity" you meant? Or did you intend something like a _wiki page_ or _deliverable_? -- **Q2:** Should bot owners (`im_bots.ownerId` / `mentorId`) be allowed to decide requests, or only workspace admins? (Spec assumes both.) -- **Q3:** v1 enforcement scope — do you want me to wire `gate(...)` into a specific call site as part of the first PR, or land the framework alone first? -- **Q4:** Do you want a hard cap on grant `expiresAt` (e.g., max 90 days), or fully unbounded? -- **Q5:** Should the spell-id word list be exposed for branding (e.g., theme switcher: "crypto" / "fantasy" / "nature"), or fixed to crypto-themed v1? +- **Q1 — `task` subject:** Confirmed = `routine__routines.id` (routine-definition level). Encoded in §3. +- **Q2 — Approvers:** The primary approver is the **resource holder**, resolved by a per-key `resolveApprovers(ctx)` function (§4). The bot may suggest extra approvers via `suggestedApproverIds` (§5.2, §8). Workspace owners are always included as a safety net. There is one canonical resolver, used by both REST and WS paths (§7.3). +- **Q3 — First enforcement point:** Ships in PR 1. Chosen call site: bot cross-channel `messages:send` (§15). +- **Q4 — Grant expiry:** No upper bound. `expiresAt` is `null` for indefinite, otherwise any future timestamp. The validator only rejects past timestamps. +- **Q5 — Spell word list:** BIP-39 English mnemonic list (§6) — the same list crypto wallets use for "secret words" / recovery phrases. ## 17. Files To Be Created / Modified (sketch) @@ -459,20 +562,22 @@ NEW: apps/server/apps/gateway/src/permissions/permissions.controller.ts apps/server/apps/gateway/src/permissions/permission-matcher.ts apps/server/apps/gateway/src/permissions/permission-keys.ts + apps/server/apps/gateway/src/permissions/permissions-approver.repository.ts # holder lookups (channel/wiki/routine/bot) apps/server/apps/gateway/src/permissions/spell-id.service.ts - apps/server/apps/gateway/src/permissions/spell-words.ts + apps/server/apps/gateway/src/permissions/spell-words.ts # BIP-39 list apps/server/apps/gateway/src/permissions/dto/*.dto.ts apps/server/apps/gateway/src/permissions/__tests__/*.spec.ts apps/server/libs/shared/src/events/domains/permissions/index.ts apps/client/src/components/permissions/{PermissionInbox,PermissionRequestCard,GrantList,GrantEditor,ScopeEditor}.tsx apps/client/src/hooks/usePermissions.ts apps/client/src/i18n/locales/{en,zh-CN}/permissions.json - packages/claw-hive/src/runtime/permissions-client.ts # team9-agent-pi monorepo + packages/claw-hive/src/runtime/permissions-client.ts # team9-agent-pi monorepo (PR 2+ only) MODIFIED: apps/server/apps/gateway/src/app.module.ts # register PermissionsModule apps/server/libs/database/src/schemas/index.ts # re-export permissions apps/server/libs/shared/src/events/index.ts # add permissions domain + apps/server/apps/gateway/src/im/messages/messages.service.ts # call gate('messages:send') for cross-channel bot posts apps/client/src/services/websocket.ts # listeners apps/client/src/stores/useAppStore.ts # pendingPermissionCount ``` From 9e3fd76c4129e21790241670cd3fcfcf89ba7d24 Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 2 May 2026 15:15:47 +0800 Subject: [PATCH 04/23] docs: implementation plan for message forwarding 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) --- .../plans/2026-05-02-message-forwarding.md | 3312 +++++++++++++++++ ...026-05-02-message-forwarding.md.tasks.json | 101 + 2 files changed, 3413 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-02-message-forwarding.md create mode 100644 docs/superpowers/plans/2026-05-02-message-forwarding.md.tasks.json diff --git a/docs/superpowers/plans/2026-05-02-message-forwarding.md b/docs/superpowers/plans/2026-05-02-message-forwarding.md new file mode 100644 index 00000000..68d9e7b8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-02-message-forwarding.md @@ -0,0 +1,3312 @@ +# Message Forwarding Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship single-message forward + multi-select bundle forward across the Team9 IM stack (gateway + im-worker contract + Tauri client) per [docs/superpowers/specs/2026-05-02-message-forwarding-design.md](../specs/2026-05-02-message-forwarding-design.md). + +**Architecture:** New `im_message_forwards` table (one row per single, N rows per bundle) holding source pointers + content/attachment snapshots, joined to a new `messageType = 'forward'` row in `im_messages`. New REST endpoints under `/api/v1/im` for create + bundle-item fetch. Existing WS `MESSAGE.NEW` event is reused; clients dispatch on `type === 'forward'` to render quote / bundle cards. Multi-select mode is a per-channel Zustand store; entry from hover toolbar + right-click; floating action bar in `MessageList`. + +**Tech Stack:** NestJS 11, Drizzle ORM (Postgres), Socket.io, Tauri 2 + React 19, TanStack Query, Zustand, Tailwind, Jest, Vitest. + +--- + +## File map + +**Server** + +- Create `apps/server/libs/database/src/schemas/im/message-forwards.ts` +- Modify `apps/server/libs/database/src/schemas/im/messages.ts` (enum) +- Modify `apps/server/libs/database/src/schemas/im/index.ts` (re-export) +- Migration file auto-generated under `apps/server/libs/database/drizzle/` +- Modify `apps/server/apps/gateway/src/im/channels/channels.service.ts` + `.spec.ts` (`assertWriteAccess`) +- Create `apps/server/apps/gateway/src/im/messages/forwards/forwards.service.ts` + `.spec.ts` +- Create `apps/server/apps/gateway/src/im/messages/forwards/forwards.controller.ts` + `.spec.ts` +- Create `apps/server/apps/gateway/src/im/messages/forwards/dto/create-forward.dto.ts` +- Create `apps/server/apps/gateway/src/im/messages/forwards/types.ts` (response shapes) +- Modify `apps/server/apps/gateway/src/im/messages/messages.module.ts` +- Modify `apps/server/apps/gateway/src/im/messages/messages.service.ts` + `.spec.ts` (forward hydration) +- Modify `apps/server/apps/gateway/src/im/messages/messages.controller.ts` (use `assertWriteAccess`; reject PATCH on forward) +- Create `apps/server/apps/gateway/test/forward.e2e-spec.ts` + +**Client** + +- Modify `apps/client/src/types/im.ts` (`MessageType`, `ForwardPayload`) +- Modify `apps/client/src/services/api.ts` (forwardMessages, getForwardItems) +- Create `apps/client/src/stores/useForwardSelectionStore.ts` + `__tests__/useForwardSelectionStore.test.ts` +- Create `apps/client/src/components/channel/forward/ForwardDialog.tsx` +- Create `apps/client/src/components/channel/forward/ForwardChannelList.tsx` +- Create `apps/client/src/components/channel/forward/ForwardPreview.tsx` (single-quote + bundle preview) +- Create `apps/client/src/components/channel/forward/ForwardedMessageCard.tsx` +- Create `apps/client/src/components/channel/forward/ForwardBundleViewer.tsx` +- Create `apps/client/src/components/channel/forward/SelectionActionBar.tsx` +- Create `apps/client/src/components/channel/forward/__tests__/*.test.tsx` for each component +- Modify `apps/client/src/components/channel/MessageHoverToolbar.tsx` +- Modify `apps/client/src/components/channel/MessageContextMenu.tsx` +- Modify `apps/client/src/components/channel/MessageItem.tsx` +- Modify `apps/client/src/components/channel/MessageList.tsx` +- Modify `apps/client/src/components/channel/MessageContent.tsx` +- Modify `apps/client/src/i18n/locales/{en,zh-CN}/channel.json` + +--- + +## Task 0: i18n strings + +**Goal:** Add all `forward.*` keys to English + Simplified Chinese locale bundles. No code consumers yet — this lands first so subsequent tasks can reference final keys. + +**Files:** + +- Modify: `apps/client/src/i18n/locales/en/channel.json` +- Modify: `apps/client/src/i18n/locales/zh-CN/channel.json` + +**Acceptance Criteria:** + +- [ ] All keys from spec §2.5 present in both locale files +- [ ] JSON is well-formed (Prettier passes) +- [ ] No unrelated key changes + +**Verify:** `pnpm --filter @team9/client lint` → no errors; `node -e "require('./apps/client/src/i18n/locales/en/channel.json')"` → no parse error. + +**Steps:** + +- [ ] **Step 1: Inspect current shape** + +```bash +grep -c '"' apps/client/src/i18n/locales/en/channel.json +head -5 apps/client/src/i18n/locales/en/channel.json +``` + +Look at the existing top-level structure (flat keys vs. nested objects). Match what's there. + +- [ ] **Step 2: Add forward namespace block to `en/channel.json`** + +Insert at the end of the JSON object (or merge into existing structure): + +```json +"forward": { + "toolbar": { + "forward": "Forward", + "select": "Select" + }, + "contextMenu": { + "forward": "Forward", + "select": "Select" + }, + "dialog": { + "titleSingle": "Forward message", + "titleBundle": "Forward {{count}} messages", + "searchPlaceholder": "Search channels…", + "confirm": "Forward", + "cancel": "Cancel" + }, + "selection": { + "bar": "{{count}} selected", + "cancel": "Cancel" + }, + "tooManySelected": "You can forward up to 100 messages at once.", + "card": { + "fromChannel": "Forwarded from #{{channelName}}" + }, + "bundle": { + "title": "Chat record · {{count}} messages", + "viewAll": "View all", + "modalTitle": "Chat record from #{{channelName}}" + }, + "source": { + "unavailable": "Source no longer available", + "jumpTo": "Jump to original" + }, + "error": { + "notAllowed": "This message can't be forwarded.", + "noWriteAccess": "You can't forward to this channel.", + "noSourceAccess": "You no longer have access to the original channel.", + "mixedChannels": "All selected messages must come from the same channel.", + "empty": "Pick at least one message to forward.", + "notFound": "Original message could not be found." + } +} +``` + +- [ ] **Step 3: Add the same block to `zh-CN/channel.json`** with translations + +```json +"forward": { + "toolbar": { "forward": "转发", "select": "选择" }, + "contextMenu": { "forward": "转发", "select": "选择" }, + "dialog": { + "titleSingle": "转发消息", + "titleBundle": "转发 {{count}} 条消息", + "searchPlaceholder": "搜索频道…", + "confirm": "发送", + "cancel": "取消" + }, + "selection": { "bar": "已选 {{count}} 条", "cancel": "取消" }, + "tooManySelected": "一次最多转发 100 条消息", + "card": { "fromChannel": "转自 #{{channelName}}" }, + "bundle": { + "title": "聊天记录 · {{count}} 条", + "viewAll": "查看全部", + "modalTitle": "来自 #{{channelName}} 的聊天记录" + }, + "source": { "unavailable": "原消息已不可访问", "jumpTo": "跳转到原消息" }, + "error": { + "notAllowed": "此消息不可转发", + "noWriteAccess": "你没有该频道的发送权限", + "noSourceAccess": "你已无法访问原频道", + "mixedChannels": "多选转发的消息必须来自同一频道", + "empty": "请至少选择一条消息进行转发", + "notFound": "找不到原消息" + } +} +``` + +- [ ] **Step 4: Verify both files parse** + +```bash +node -e "JSON.parse(require('fs').readFileSync('apps/client/src/i18n/locales/en/channel.json','utf8'))" +node -e "JSON.parse(require('fs').readFileSync('apps/client/src/i18n/locales/zh-CN/channel.json','utf8'))" +``` + +Expected: both commands exit 0 silently. + +- [ ] **Step 5: Run client lint** + +```bash +pnpm --filter @team9/client lint +``` + +Expected: PASS (or no new errors vs. pre-change baseline). + +- [ ] **Step 6: Commit** + +```bash +git add apps/client/src/i18n/locales/en/channel.json apps/client/src/i18n/locales/zh-CN/channel.json +git commit -m "feat(im): add forward.* i18n strings for en + zh-CN" +``` + +--- + +## Task 1: DB schema — `forward` enum value + `im_message_forwards` table + +**Goal:** Land the schema and migration for forward storage. After this task, the table exists in the dev DB and the enum has the new value, but no code uses them yet. + +**Files:** + +- Modify: `apps/server/libs/database/src/schemas/im/messages.ts` +- Create: `apps/server/libs/database/src/schemas/im/message-forwards.ts` +- Modify: `apps/server/libs/database/src/schemas/im/index.ts` +- Generated: `apps/server/libs/database/drizzle/_.sql` (drizzle output) +- Test: `apps/server/libs/database/src/schemas/im/message-forwards.spec.ts` + +**Acceptance Criteria:** + +- [ ] `messageTypeEnum` includes `'forward'` as the last value (preserves existing ordinals). +- [ ] `messageForwards` table compiles with the exact columns + indexes from spec §3.2. +- [ ] `pnpm db:generate` produces a single forward-only migration; `pnpm db:migrate` applies it cleanly to a fresh DB. +- [ ] Schema unit test verifies `position`, FK actions, and the cascade behavior on `forwardedMessageId` delete. + +**Verify:** + +```bash +pnpm db:generate # should produce ONE new SQL file +pnpm --filter @team9/database test # spec passes +``` + +**Steps:** + +- [ ] **Step 1: Extend the enum** + +Edit `apps/server/libs/database/src/schemas/im/messages.ts:16-23`: + +```ts +export const messageTypeEnum = pgEnum("message_type", [ + "text", + "file", + "image", + "system", + "tracking", + "long_text", + "forward", +]); +``` + +- [ ] **Step 2: Create the schema file** + +Create `apps/server/libs/database/src/schemas/im/message-forwards.ts`: + +```ts +import { + pgTable, + uuid, + integer, + timestamp, + jsonb, + varchar, + bigint, + index, +} from "drizzle-orm/pg-core"; +import { messages } from "./messages.js"; +import { channels } from "./channels.js"; +import { tenants } from "../tenant/tenants.js"; +import { users } from "./users.js"; + +export interface ForwardAttachmentSnapshot { + originalAttachmentId: string; + fileName: string; + fileUrl: string; + fileKey: string | null; + fileSize: number; + mimeType: string; + thumbnailUrl: string | null; + width: number | null; + height: number | null; +} + +export const messageForwards = pgTable( + "im_message_forwards", + { + id: uuid("id").primaryKey().defaultRandom(), + forwardedMessageId: uuid("forwarded_message_id") + .references(() => messages.id, { onDelete: "cascade" }) + .notNull(), + position: integer("position").notNull(), + sourceMessageId: uuid("source_message_id").references(() => messages.id, { + onDelete: "set null", + }), + sourceChannelId: uuid("source_channel_id") + .references(() => channels.id) + .notNull(), + sourceWorkspaceId: uuid("source_workspace_id").references( + () => tenants.id, + { onDelete: "set null" }, + ), + sourceSenderId: uuid("source_sender_id").references(() => users.id, { + onDelete: "set null", + }), + sourceCreatedAt: timestamp("source_created_at").notNull(), + sourceSeqId: bigint("source_seq_id", { mode: "bigint" }), + contentSnapshot: varchar("content_snapshot", { length: 100_000 }), + contentAstSnapshot: jsonb("content_ast_snapshot").$type< + Record + >(), + attachmentsSnapshot: jsonb("attachments_snapshot").$type< + ForwardAttachmentSnapshot[] + >(), + sourceType: varchar("source_type", { length: 32 }).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [ + index("idx_mf_forwarded").on(table.forwardedMessageId), + index("idx_mf_source_msg").on(table.sourceMessageId), + index("idx_mf_source_channel").on(table.sourceChannelId), + index("idx_mf_source_workspace").on(table.sourceWorkspaceId), + ], +); + +export type MessageForward = typeof messageForwards.$inferSelect; +export type NewMessageForward = typeof messageForwards.$inferInsert; +``` + +- [ ] **Step 3: Re-export from the IM schema index** + +Edit `apps/server/libs/database/src/schemas/im/index.ts` — add the line in alphabetical order with the other exports: + +```ts +export * from "./message-forwards.js"; +``` + +- [ ] **Step 4: Generate the migration** + +```bash +pnpm db:generate +``` + +Expected: one new file under `apps/server/libs/database/drizzle/` (the next sequential number) containing: + +- `ALTER TYPE "message_type" ADD VALUE 'forward';` +- `CREATE TABLE "im_message_forwards" (...);` +- The four `CREATE INDEX` statements. + +Inspect the file. If drizzle splits the enum alter and the table create into separate files, that's fine — they must apply in order. + +- [ ] **Step 5: Apply the migration locally** + +```bash +pnpm db:migrate +``` + +Expected: clean apply, no errors. Verify in psql: + +```bash +psql "$DATABASE_URL" -c "\d im_message_forwards" +psql "$DATABASE_URL" -c "SELECT unnest(enum_range(NULL::message_type));" +``` + +Expected: table present with all columns; enum includes `forward`. + +- [ ] **Step 6: Write the schema spec** + +Create `apps/server/libs/database/src/schemas/im/message-forwards.spec.ts`. Mirror the style of `message-relations.spec.ts` (read it first for harness conventions): + +```ts +import { describe, it, expect, beforeEach, afterEach } from "@jest/globals"; +import { sql } from "drizzle-orm"; +import { v7 as uuidv7 } from "uuid"; +import { setupTestDb, teardownTestDb, type TestDb } from "../../test-utils.js"; +import { messageForwards } from "./message-forwards.js"; +import { messages } from "./messages.js"; +import { channels } from "./channels.js"; +import { users } from "./users.js"; + +describe("im_message_forwards schema", () => { + let db: TestDb; + beforeEach(async () => { + db = await setupTestDb(); + }); + afterEach(async () => { + await teardownTestDb(db); + }); + + async function seedChannelAndUser() { + const userId = uuidv7(); + const channelId = uuidv7(); + await db.client.insert(users).values({ + id: userId, + username: `u-${userId}`, + displayName: "U", + email: `${userId}@x.test`, + passwordHash: "x", + }); + await db.client.insert(channels).values({ + id: channelId, + name: "src", + type: "public", + createdBy: userId, + }); + return { userId, channelId }; + } + + async function seedMessage(channelId: string, senderId: string) { + const id = uuidv7(); + await db.client.insert(messages).values({ + id, + channelId, + senderId, + content: "hi", + type: "text", + }); + return id; + } + + it("inserts a single forward row with position 0", async () => { + const { userId, channelId } = await seedChannelAndUser(); + const sourceId = await seedMessage(channelId, userId); + const forwardMsgId = await seedMessage(channelId, userId); + await db.client + .update(messages) + .set({ type: "forward" }) + .where(sql`${messages.id} = ${forwardMsgId}`); + const [row] = await db.client + .insert(messageForwards) + .values({ + forwardedMessageId: forwardMsgId, + position: 0, + sourceMessageId: sourceId, + sourceChannelId: channelId, + sourceSenderId: userId, + sourceCreatedAt: new Date(), + contentSnapshot: "hi", + sourceType: "text", + }) + .returning(); + expect(row.position).toBe(0); + expect(row.sourceMessageId).toBe(sourceId); + }); + + it("cascades on forwarded_message delete", async () => { + const { userId, channelId } = await seedChannelAndUser(); + const sourceId = await seedMessage(channelId, userId); + const fwdId = await seedMessage(channelId, userId); + await db.client + .update(messages) + .set({ type: "forward" }) + .where(sql`${messages.id} = ${fwdId}`); + await db.client.insert(messageForwards).values({ + forwardedMessageId: fwdId, + position: 0, + sourceMessageId: sourceId, + sourceChannelId: channelId, + sourceSenderId: userId, + sourceCreatedAt: new Date(), + sourceType: "text", + }); + await db.client.delete(messages).where(sql`${messages.id} = ${fwdId}`); + const remaining = await db.client + .select() + .from(messageForwards) + .where(sql`${messageForwards.forwardedMessageId} = ${fwdId}`); + expect(remaining).toHaveLength(0); + }); + + it("sets sourceMessageId NULL when source is deleted", async () => { + const { userId, channelId } = await seedChannelAndUser(); + const sourceId = await seedMessage(channelId, userId); + const fwdId = await seedMessage(channelId, userId); + await db.client + .update(messages) + .set({ type: "forward" }) + .where(sql`${messages.id} = ${fwdId}`); + await db.client.insert(messageForwards).values({ + forwardedMessageId: fwdId, + position: 0, + sourceMessageId: sourceId, + sourceChannelId: channelId, + sourceSenderId: userId, + sourceCreatedAt: new Date(), + sourceType: "text", + }); + await db.client.delete(messages).where(sql`${messages.id} = ${sourceId}`); + const [row] = await db.client + .select() + .from(messageForwards) + .where(sql`${messageForwards.forwardedMessageId} = ${fwdId}`); + expect(row.sourceMessageId).toBeNull(); + expect(row.sourceChannelId).toBe(channelId); // denorm survives + }); + + it("rejects insert when sourceChannelId is null", async () => { + const { userId, channelId } = await seedChannelAndUser(); + const fwdId = await seedMessage(channelId, userId); + await db.client + .update(messages) + .set({ type: "forward" }) + .where(sql`${messages.id} = ${fwdId}`); + await expect( + db.client.insert(messageForwards).values({ + forwardedMessageId: fwdId, + position: 0, + sourceChannelId: null as unknown as string, + sourceCreatedAt: new Date(), + sourceType: "text", + }), + ).rejects.toThrow(); + }); +}); +``` + +If `setupTestDb`/`teardownTestDb` helpers don't exist in this repo, **first** check `apps/server/libs/database/src/` for the existing test bootstrap pattern (e.g. a Testcontainers wrapper used by `message-relations.spec.ts`) and adapt the imports accordingly. Do not invent a parallel harness — use what's there. + +- [ ] **Step 7: Run the schema spec** + +```bash +pnpm --filter @team9/database test -- message-forwards +``` + +Expected: 4 specs pass. + +- [ ] **Step 8: Commit** + +```bash +git add apps/server/libs/database/src/schemas/im/messages.ts \ + apps/server/libs/database/src/schemas/im/message-forwards.ts \ + apps/server/libs/database/src/schemas/im/index.ts \ + apps/server/libs/database/src/schemas/im/message-forwards.spec.ts \ + apps/server/libs/database/drizzle/ +git commit -m "feat(db): add im_message_forwards table and 'forward' message type" +``` + +--- + +## Task 2: `ChannelsService.assertWriteAccess` + +**Goal:** Extract the inline write-access checks currently buried inside `MessagesController.createChannelMessage` into a reusable `assertWriteAccess(channelId, userId)` method on `ChannelsService`. Refactor the existing call site to use it. After this, both `createMessage` and (later) `forward` share one path for "can this user post here right now". + +**Files:** + +- Modify: `apps/server/apps/gateway/src/im/channels/channels.service.ts` +- Modify: `apps/server/apps/gateway/src/im/channels/channels.service.spec.ts` +- Modify: `apps/server/apps/gateway/src/im/messages/messages.controller.ts` +- Modify: `apps/server/apps/gateway/src/im/messages/messages.controller.spec.ts` (if existing tests cover the rejection paths) + +**Acceptance Criteria:** + +- [ ] `assertWriteAccess` throws `ForbiddenException('forward.noWriteAccess')`-equivalent string when user is not a member. +- [ ] Throws when channel `isArchived` (`'Channel is archived ...'`). +- [ ] Throws when channel `isActivated === false` (`'Channel is deactivated ...'`). +- [ ] Existing `createChannelMessage` flow still works (regression: existing controller spec passes). +- [ ] New unit tests cover happy + each rejection branch. + +**Verify:** + +```bash +pnpm --filter @team9/server test -- channels.service +pnpm --filter @team9/server test -- messages.controller +``` + +Both expected to pass. + +**Steps:** + +- [ ] **Step 1: Read existing logic and decide error strings** + +Read `apps/server/apps/gateway/src/im/messages/messages.controller.ts:78-114` for the four current rejection strings (`'Access denied'`, `'Channel is deactivated — execution has completed'`, `'Channel is archived and no longer accepts new messages'`). Keep the **same human-readable strings** to avoid breaking any client that parses them — we are refactoring, not redesigning the messages. + +- [ ] **Step 2: Add `assertWriteAccess` to `ChannelsService`** + +In `apps/server/apps/gateway/src/im/channels/channels.service.ts`, immediately after `assertReadAccess` (around line 1792), add: + +```ts +/** + * Asserts the user can post a new message to this channel. + * Throws ForbiddenException with the same human-readable strings the + * messages controller has been throwing inline since the project started. + */ +async assertWriteAccess(channelId: string, userId: string): Promise { + const isMember = await this.isMember(channelId, userId); + if (!isMember) { + throw new ForbiddenException('Access denied'); + } + const channel = await this.findById(channelId); + if (!channel) { + throw new ForbiddenException('Access denied'); + } + if (!channel.isActivated) { + throw new ForbiddenException( + 'Channel is deactivated — execution has completed', + ); + } + if (channel.isArchived) { + throw new ForbiddenException( + 'Channel is archived and no longer accepts new messages', + ); + } +} +``` + +If `ForbiddenException` is not yet imported in this file, add it to the existing `@nestjs/common` import line. + +- [ ] **Step 3: Refactor `createChannelMessage`** + +In `apps/server/apps/gateway/src/im/messages/messages.controller.ts:85-114`, replace: + +```ts +const isMember = await this.channelsService.isMember(channelId, userId); +const t1 = Date.now(); + +if (!isMember) { + throw new ForbiddenException("Access denied"); +} + +const clientMsgId = dto.clientMsgId || uuidv7(); + +const channel = await this.channelsService.findById(channelId); +const t2 = Date.now(); +const workspaceId = channel?.tenantId ?? undefined; + +if (channel && !channel.isActivated) { + throw new ForbiddenException( + "Channel is deactivated — execution has completed", + ); +} + +if (channel && channel.isArchived) { + throw new ForbiddenException( + "Channel is archived and no longer accepts new messages", + ); +} +``` + +with: + +```ts +await this.channelsService.assertWriteAccess(channelId, userId); +const t1 = Date.now(); + +const clientMsgId = dto.clientMsgId || uuidv7(); + +const channel = await this.channelsService.findById(channelId); +const t2 = Date.now(); +const workspaceId = channel?.tenantId ?? undefined; +``` + +The two timing markers `t1`/`t2` are preserved (they feed the existing slow-log warning). Note the `channel` lookup remains because the rest of the function uses `workspaceId`, `channel.type`, etc. + +- [ ] **Step 4: Add unit tests** + +Append to `apps/server/apps/gateway/src/im/channels/channels.service.spec.ts` (after the `assertReadAccess` describe block): + +```ts +describe("assertWriteAccess", () => { + it("passes for member of an active, non-archived channel", async () => { + mockIsMember.mockResolvedValueOnce(true); + mockFindById.mockResolvedValueOnce({ + id: "ch-1", + isActivated: true, + isArchived: false, + } as Channel); + await expect( + service.assertWriteAccess("ch-1", "u-1"), + ).resolves.toBeUndefined(); + }); + + it("throws for non-member", async () => { + mockIsMember.mockResolvedValueOnce(false); + await expect(service.assertWriteAccess("ch-1", "u-1")).rejects.toThrow( + "Access denied", + ); + }); + + it("throws when channel not found", async () => { + mockIsMember.mockResolvedValueOnce(true); + mockFindById.mockResolvedValueOnce(null); + await expect(service.assertWriteAccess("ch-1", "u-1")).rejects.toThrow( + "Access denied", + ); + }); + + it("throws for deactivated channel", async () => { + mockIsMember.mockResolvedValueOnce(true); + mockFindById.mockResolvedValueOnce({ + id: "ch-1", + isActivated: false, + isArchived: false, + } as Channel); + await expect(service.assertWriteAccess("ch-1", "u-1")).rejects.toThrow( + "deactivated", + ); + }); + + it("throws for archived channel", async () => { + mockIsMember.mockResolvedValueOnce(true); + mockFindById.mockResolvedValueOnce({ + id: "ch-1", + isActivated: true, + isArchived: true, + } as Channel); + await expect(service.assertWriteAccess("ch-1", "u-1")).rejects.toThrow( + "archived", + ); + }); +}); +``` + +Reuse whatever mocks (`mockIsMember`, `mockFindById`, etc.) the existing `assertReadAccess` describe block uses — read it first to match the pattern. If the spec uses `jest.spyOn(service, 'isMember')` instead of mock variables, follow that style. + +- [ ] **Step 5: Run tests** + +```bash +pnpm --filter @team9/server test -- channels.service +pnpm --filter @team9/server test -- messages.controller +``` + +Expected: all pass, including the new `assertWriteAccess` describe block. + +- [ ] **Step 6: Commit** + +```bash +git add apps/server/apps/gateway/src/im/channels/channels.service.ts \ + apps/server/apps/gateway/src/im/channels/channels.service.spec.ts \ + apps/server/apps/gateway/src/im/messages/messages.controller.ts +git commit -m "refactor(im): extract assertWriteAccess into ChannelsService" +``` + +--- + +## Task 3: `ForwardsService` — core business logic + +**Goal:** Build the service that takes `(targetChannelId, sourceChannelId, sourceMessageIds, userId)`, validates everything, builds the snapshot rows, creates the new `forward`-type message via the existing `imWorkerGrpcClientService.createMessage` path, and inserts the `im_message_forwards` rows. Includes `getForwardItems(messageId, userId)` for the bundle-viewer endpoint and a `hydrateForward(messageId)` helper consumed by `MessagesService` in Task 5. + +**Files:** + +- Create: `apps/server/apps/gateway/src/im/messages/forwards/forwards.service.ts` +- Create: `apps/server/apps/gateway/src/im/messages/forwards/forwards.service.spec.ts` +- Create: `apps/server/apps/gateway/src/im/messages/forwards/types.ts` + +**Acceptance Criteria:** + +- [ ] `forward()` happy paths: single text, single image (with attachment), single long_text, single forward (re-forward), bundle of 5 mixed types — all return a `MessageResponse` with `type === 'forward'` and `forward.items.length === N`. +- [ ] Rejection paths return the right error codes from spec §4.5: empty, >100, mixed channels, disallowed type, streaming, not found. +- [ ] On forward-row insert failure the new message is soft-deleted and a 500 (`InternalServerErrorException`) is thrown. +- [ ] `getForwardItems(messageId, userId)` enforces read access on the forward message's channel and returns ordered `ForwardItemResponse[]`. +- [ ] Snapshot truncation: when source `content` exceeds 100k chars, snapshot is truncated and `truncated: true` is set on both the row and `metadata.forward.truncated`. +- [ ] No row is inserted in `im_message_attachments` for the forward message; attachments live only in `attachmentsSnapshot`. +- [ ] 100% line + branch coverage for `forwards.service.ts`. + +**Verify:** + +```bash +pnpm --filter @team9/server test -- forwards.service --coverage +``` + +Coverage report shows 100/100 for the new file. + +**Steps:** + +- [ ] **Step 1: Write the response/types module first** + +Create `apps/server/apps/gateway/src/im/messages/forwards/types.ts`: + +```ts +import type { ForwardAttachmentSnapshot } from "@team9/database"; + +export type ForwardKind = "single" | "bundle"; + +export interface ForwardSourceUser { + id: string; + username: string; + displayName: string | null; + avatarUrl: string | null; +} + +export interface ForwardItemResponse { + position: number; + sourceMessageId: string | null; + sourceChannelId: string; + sourceChannelName: string | null; + sourceWorkspaceId: string | null; + sourceSender: ForwardSourceUser | null; + sourceCreatedAt: string; + sourceSeqId: string | null; + sourceType: "text" | "long_text" | "file" | "image" | "forward"; + contentSnapshot: string | null; + contentAstSnapshot: Record | null; + attachmentsSnapshot: ForwardAttachmentSnapshot[]; + canJumpToOriginal: boolean; + truncated: boolean; +} + +export interface ForwardPayload { + kind: ForwardKind; + count: number; + sourceChannelId: string; + sourceChannelName: string | null; + truncated: boolean; + items: ForwardItemResponse[]; +} + +export interface ForwardMetadata { + kind: ForwardKind; + count: number; + sourceChannelId: string; + sourceChannelName: string; + truncated?: boolean; +} + +export const FORWARD_CONTENT_SNAPSHOT_LIMIT = 100_000; +export const FORWARD_BUNDLE_LIMIT = 100; +export const FORWARDABLE_SOURCE_TYPES = new Set([ + "text", + "long_text", + "file", + "image", + "forward", +]); +``` + +- [ ] **Step 2: Write failing service skeleton + first test** + +Create `apps/server/apps/gateway/src/im/messages/forwards/forwards.service.ts` (stub) and `forwards.service.spec.ts`. Start with the test for "rejects empty selection": + +```ts +// forwards.service.spec.ts +import { Test, TestingModule } from "@nestjs/testing"; +import { BadRequestException, ForbiddenException } from "@nestjs/common"; +import { ForwardsService } from "./forwards.service.js"; +import { ChannelsService } from "../../channels/channels.service.js"; +import { MessagesService } from "../messages.service.js"; +import { ImWorkerGrpcClientService } from "../../services/im-worker-grpc-client.service.js"; +import { DatabaseService } from "@team9/database"; + +describe("ForwardsService", () => { + let service: ForwardsService; + let channels: jest.Mocked; + let messages: jest.Mocked; + let grpc: jest.Mocked; + let db: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ForwardsService, + { + provide: ChannelsService, + useValue: { + assertReadAccess: jest.fn(), + assertWriteAccess: jest.fn(), + findById: jest.fn(), + }, + }, + { + provide: MessagesService, + useValue: { + findManyByIds: jest.fn(), + getMessageWithDetails: jest.fn(), + softDelete: jest.fn(), + truncateForPreview: jest.fn((m) => m), + getAttachmentsForMessages: jest.fn(), + }, + }, + { + provide: ImWorkerGrpcClientService, + useValue: { createMessage: jest.fn() }, + }, + { + provide: DatabaseService, + useValue: { + db: { + insert: jest + .fn() + .mockReturnValue({ values: jest.fn().mockResolvedValue([]) }), + }, + }, + }, + ], + }).compile(); + service = module.get(ForwardsService); + channels = module.get(ChannelsService); + messages = module.get(MessagesService); + grpc = module.get(ImWorkerGrpcClientService); + db = module.get(DatabaseService); + }); + + it("rejects empty sourceMessageIds", async () => { + await expect( + service.forward({ + targetChannelId: "ch-target", + sourceChannelId: "ch-src", + sourceMessageIds: [], + userId: "u-1", + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); +``` + +Run: + +```bash +pnpm --filter @team9/server test -- forwards.service +``` + +Expected: FAIL with "ForwardsService is not defined" (or similar). + +- [ ] **Step 3: Implement the service** + +Create the full `forwards.service.ts`: + +```ts +import { + Injectable, + Logger, + BadRequestException, + NotFoundException, + InternalServerErrorException, + ForbiddenException, + Inject, + forwardRef, +} from "@nestjs/common"; +import { v7 as uuidv7 } from "uuid"; +import { sql, inArray, eq } from "drizzle-orm"; +import { + DatabaseService, + messageForwards, + messages as messagesTable, + type ForwardAttachmentSnapshot, + type NewMessageForward, +} from "@team9/database"; +import { ChannelsService } from "../../channels/channels.service.js"; +import { MessagesService, type MessageResponse } from "../messages.service.js"; +import { ImWorkerGrpcClientService } from "../../services/im-worker-grpc-client.service.js"; +import { + FORWARD_BUNDLE_LIMIT, + FORWARD_CONTENT_SNAPSHOT_LIMIT, + FORWARDABLE_SOURCE_TYPES, + type ForwardItemResponse, + type ForwardKind, + type ForwardMetadata, + type ForwardPayload, +} from "./types.js"; + +interface ForwardInput { + targetChannelId: string; + sourceChannelId: string; + sourceMessageIds: string[]; + clientMsgId?: string; + userId: string; +} + +@Injectable() +export class ForwardsService { + private readonly logger = new Logger(ForwardsService.name); + + constructor( + private readonly channelsService: ChannelsService, + @Inject(forwardRef(() => MessagesService)) + private readonly messagesService: MessagesService, + private readonly grpc: ImWorkerGrpcClientService, + private readonly databaseService: DatabaseService, + ) {} + + async forward(input: ForwardInput): Promise { + const { targetChannelId, sourceChannelId, sourceMessageIds, userId } = + input; + + if (sourceMessageIds.length === 0) { + throw new BadRequestException("forward.empty"); + } + if (sourceMessageIds.length > FORWARD_BUNDLE_LIMIT) { + throw new BadRequestException("forward.tooManySelected"); + } + + await this.channelsService + .assertReadAccess(sourceChannelId, userId) + .catch((e) => { + throw new ForbiddenException("forward.noSourceAccess"); + }); + await this.channelsService + .assertWriteAccess(targetChannelId, userId) + .catch((e) => { + throw new ForbiddenException("forward.noWriteAccess"); + }); + + const sourceMessages = + await this.messagesService.findManyByIds(sourceMessageIds); + if (sourceMessages.length !== sourceMessageIds.length) { + throw new NotFoundException("forward.notFound"); + } + for (const m of sourceMessages) { + if (m.channelId !== sourceChannelId) + throw new BadRequestException("forward.mixedChannels"); + if (m.isDeleted) throw new BadRequestException("forward.notAllowed"); + if (!FORWARDABLE_SOURCE_TYPES.has(m.type)) + throw new BadRequestException("forward.notAllowed"); + const meta = (m.metadata ?? {}) as Record; + if (meta.streaming === true) + throw new BadRequestException("forward.notAllowed"); + } + + const ordered = sourceMessageIds.map((id) => { + const m = sourceMessages.find((s) => s.id === id); + if (!m) throw new NotFoundException("forward.notFound"); + return m; + }); + + const attachmentsByMessage = + await this.messagesService.getAttachmentsForMessages( + ordered.map((m) => m.id), + ); + + const sourceChannel = await this.channelsService.findById(sourceChannelId); + const sourceChannelName = sourceChannel?.name ?? null; + + const kind: ForwardKind = ordered.length === 1 ? "single" : "bundle"; + + const items: Array<{ row: NewMessageForward; truncated: boolean }> = + ordered.map((m, position) => { + const attachments = (attachmentsByMessage.get(m.id) ?? []).map( + (a): ForwardAttachmentSnapshot => ({ + originalAttachmentId: a.id, + fileName: a.fileName, + fileUrl: a.fileUrl, + fileKey: a.fileKey, + fileSize: a.fileSize, + mimeType: a.mimeType, + thumbnailUrl: a.thumbnailUrl, + width: a.width, + height: a.height, + }), + ); + + let snapshot = m.content ?? null; + let truncated = false; + if (snapshot && snapshot.length > FORWARD_CONTENT_SNAPSHOT_LIMIT) { + snapshot = snapshot.slice(0, FORWARD_CONTENT_SNAPSHOT_LIMIT); + truncated = true; + } + + return { + truncated, + row: { + forwardedMessageId: "__placeholder__", // patched after createMessage + position, + sourceMessageId: m.id, + sourceChannelId, + sourceWorkspaceId: sourceChannel?.tenantId ?? null, + sourceSenderId: m.senderId, + sourceCreatedAt: m.createdAt, + sourceSeqId: m.seqId ?? null, + contentSnapshot: snapshot, + contentAstSnapshot: + (m.contentAst as Record | null) ?? null, + attachmentsSnapshot: attachments, + sourceType: m.type, + }, + }; + }); + + const anyTruncated = items.some((i) => i.truncated); + const digest = this.buildDigest(kind, ordered, sourceChannelName); + const metadataForward: ForwardMetadata = { + kind, + count: ordered.length, + sourceChannelId, + sourceChannelName: sourceChannelName ?? "", + ...(anyTruncated && { truncated: true }), + }; + + const targetChannel = await this.channelsService.findById(targetChannelId); + const created = await this.grpc.createMessage({ + clientMsgId: input.clientMsgId ?? uuidv7(), + channelId: targetChannelId, + senderId: userId, + content: digest, + contentAst: undefined, + type: "forward", + workspaceId: targetChannel?.tenantId ?? undefined, + attachments: undefined, + metadata: { forward: metadataForward }, + }); + + const forwardedMessageId = created.msgId; + try { + await this.databaseService.db + .insert(messageForwards) + .values(items.map((i) => ({ ...i.row, forwardedMessageId }))); + } catch (err) { + this.logger.error( + `Failed to insert forward rows for ${forwardedMessageId}: ${String(err)}`, + ); + await this.messagesService.softDelete(forwardedMessageId, userId); + throw new InternalServerErrorException("forward.insertFailed"); + } + + const message = + await this.messagesService.getMessageWithDetails(forwardedMessageId); + return this.messagesService.truncateForPreview(message); + } + + async getForwardItems( + forwardedMessageId: string, + userId: string, + ): Promise { + const channelId = + await this.messagesService.getMessageChannelId(forwardedMessageId); + await this.channelsService.assertReadAccess(channelId, userId); + return this.hydrate(forwardedMessageId, userId); + } + + async hydrate( + forwardedMessageId: string, + userId: string, + ): Promise { + const rows = await this.databaseService.db + .select() + .from(messageForwards) + .where(eq(messageForwards.forwardedMessageId, forwardedMessageId)) + .orderBy(messageForwards.position); + if (rows.length === 0) return []; + + const distinctChannelIds = Array.from( + new Set(rows.map((r) => r.sourceChannelId)), + ); + const distinctSenderIds = Array.from( + new Set( + rows.map((r) => r.sourceSenderId).filter((x): x is string => !!x), + ), + ); + const distinctSourceMsgIds = rows + .map((r) => r.sourceMessageId) + .filter((x): x is string => !!x); + + const [channels, senders, liveSources] = await Promise.all([ + this.channelsService.findManyByIds(distinctChannelIds), + this.messagesService.findUsersByIds(distinctSenderIds), + distinctSourceMsgIds.length + ? this.messagesService.findManyByIds(distinctSourceMsgIds) + : Promise.resolve([]), + ]); + const channelMap = new Map(channels.map((c) => [c.id, c])); + const senderMap = new Map(senders.map((u) => [u.id, u])); + const liveSourceIds = new Set( + liveSources.filter((m) => !m.isDeleted).map((m) => m.id), + ); + + const accessByChannel = new Map(); + await Promise.all( + distinctChannelIds.map(async (cid) => { + const ok = await this.channelsService.canRead(cid, userId); + accessByChannel.set(cid, ok); + }), + ); + + return rows.map((r): ForwardItemResponse => { + const ch = channelMap.get(r.sourceChannelId); + const sender = r.sourceSenderId ? senderMap.get(r.sourceSenderId) : null; + const userCanReadSource = accessByChannel.get(r.sourceChannelId) ?? false; + const sourceStillExists = + !!r.sourceMessageId && liveSourceIds.has(r.sourceMessageId); + const truncated = + !!r.contentSnapshot && + r.contentSnapshot.length === FORWARD_CONTENT_SNAPSHOT_LIMIT; + + return { + position: r.position, + sourceMessageId: r.sourceMessageId, + sourceChannelId: r.sourceChannelId, + sourceChannelName: userCanReadSource ? (ch?.name ?? null) : null, + sourceWorkspaceId: r.sourceWorkspaceId, + sourceSender: sender + ? { + id: sender.id, + username: sender.username, + displayName: sender.displayName, + avatarUrl: sender.avatarUrl ?? null, + } + : null, + sourceCreatedAt: r.sourceCreatedAt.toISOString(), + sourceSeqId: r.sourceSeqId !== null ? r.sourceSeqId.toString() : null, + sourceType: r.sourceType as ForwardItemResponse["sourceType"], + contentSnapshot: r.contentSnapshot, + contentAstSnapshot: r.contentAstSnapshot, + attachmentsSnapshot: r.attachmentsSnapshot ?? [], + canJumpToOriginal: sourceStillExists && userCanReadSource, + truncated, + }; + }); + } + + async hydratePayload( + forwardedMessageId: string, + userId: string, + metadataForward: ForwardMetadata, + ): Promise { + const items = await this.hydrate(forwardedMessageId, userId); + return { + kind: metadataForward.kind, + count: metadataForward.count, + sourceChannelId: metadataForward.sourceChannelId, + sourceChannelName: metadataForward.sourceChannelName || null, + truncated: metadataForward.truncated ?? items.some((i) => i.truncated), + items, + }; + } + + private buildDigest( + kind: ForwardKind, + sources: { content: string | null; senderId: string | null }[], + channelName: string | null, + ): string { + if (kind === "single") { + const m = sources[0]; + const head = (m.content ?? "").slice(0, 200); + return `[Forwarded] ${head}`; + } + const previews = sources + .slice(0, 3) + .map((m) => (m.content ?? "").slice(0, 80)) + .join("; "); + return `[Forwarded chat record · ${sources.length} messages from #${channelName ?? "channel"}] ${previews}`; + } +} +``` + +Note: this depends on three small new helpers on `MessagesService` (`findManyByIds`, `getAttachmentsForMessages`, `findUsersByIds`, `softDelete`, `getMessageChannelId`) and one on `ChannelsService` (`canRead`, `findManyByIds`). Some of those already exist (e.g. `getMessageChannelId`). Add the missing ones in this same task — they're trivial wrappers. After writing the service, scan it for any calls that don't exist yet and add minimal implementations to the corresponding services with no behavior change risk (each helper is a thin DB query). Each new helper should also gain a one-line spec assertion in the corresponding existing `*.service.spec.ts` for coverage. + +- [ ] **Step 4: Expand the spec to cover every branch** + +In `forwards.service.spec.ts`, add `describe` blocks for each scenario in the Acceptance Criteria. Use the same Test module pattern from Step 2. For each test, set up the mocks to drive the target branch and assert either the resolved `MessageResponse` shape or the rejection. Cover, at minimum: + +- empty selection → `BadRequestException('forward.empty')` +- `sourceMessageIds.length > 100` → `BadRequestException('forward.tooManySelected')` +- mixed source channels → `BadRequestException('forward.mixedChannels')` +- disallowed type (`system`) → `BadRequestException('forward.notAllowed')` +- streaming source → `BadRequestException('forward.notAllowed')` +- isDeleted source → `BadRequestException('forward.notAllowed')` +- missing source → `NotFoundException('forward.notFound')` +- read-access missing → `ForbiddenException('forward.noSourceAccess')` +- write-access missing → `ForbiddenException('forward.noWriteAccess')` +- single happy path: text, image (with one attachment in `attachmentsByMessage`), long_text, re-forward (`type === 'forward'`) +- bundle of 5 mixed types — assert positions 0..4, `kind === 'bundle'`, `count === 5` +- snapshot truncation when `content.length > 100_000` +- forward-row insert failure → `softDelete` is called and `InternalServerErrorException` is thrown +- `getForwardItems` enforces read access; returns ordered items +- `hydrate` builds `canJumpToOriginal === false` when source is hard-deleted, and `false` when user has no access to source channel +- `hydrate` returns `[]` for an unknown id + +For attachments, assert that `grpc.createMessage` is called with `attachments: undefined` (the forward message owns no attachment rows). + +- [ ] **Step 5: Run with coverage** + +```bash +pnpm --filter @team9/server test -- forwards.service --coverage --collectCoverageFrom='**/forwards/**' +``` + +Expected: 100% line + branch on `forwards.service.ts` and `types.ts`. Iterate until each red branch has a test. + +- [ ] **Step 6: Commit** + +```bash +git add apps/server/apps/gateway/src/im/messages/forwards/ \ + apps/server/apps/gateway/src/im/channels/channels.service.ts \ + apps/server/apps/gateway/src/im/messages/messages.service.ts +git commit -m "feat(im): add ForwardsService with snapshot capture and hydration" +``` + +--- + +## Task 4: `ForwardsController` — REST endpoints + +**Goal:** Expose `POST /api/v1/im/channels/:targetChannelId/forward` and `GET /api/v1/im/messages/:id/forward-items`. Wire the controller into `MessagesModule`. + +**Files:** + +- Create: `apps/server/apps/gateway/src/im/messages/forwards/forwards.controller.ts` +- Create: `apps/server/apps/gateway/src/im/messages/forwards/forwards.controller.spec.ts` +- Create: `apps/server/apps/gateway/src/im/messages/forwards/dto/create-forward.dto.ts` +- Modify: `apps/server/apps/gateway/src/im/messages/messages.module.ts` + +**Acceptance Criteria:** + +- [ ] `POST` accepts `{ sourceChannelId, sourceMessageIds, clientMsgId? }`, returns the new `MessageResponse`. +- [ ] DTO validation rejects non-UUID strings, missing fields, `sourceMessageIds.length === 0` (class-validator). +- [ ] `GET /messages/:id/forward-items` returns the full `ForwardItemResponse[]`. +- [ ] Both endpoints guarded by `AuthGuard` and pull `userId` via `@CurrentUser('sub')`. +- [ ] Controller spec covers happy + each error path mirrored from the service. + +**Verify:** + +```bash +pnpm --filter @team9/server test -- forwards.controller +``` + +**Steps:** + +- [ ] **Step 1: Write the DTO** + +Create `apps/server/apps/gateway/src/im/messages/forwards/dto/create-forward.dto.ts`: + +```ts +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsOptional, + IsString, + IsUUID, +} from "class-validator"; + +export class CreateForwardDto { + @IsUUID() + sourceChannelId!: string; + + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(100) + @IsUUID("all", { each: true }) + sourceMessageIds!: string[]; + + @IsOptional() + @IsString() + clientMsgId?: string; +} +``` + +- [ ] **Step 2: Write the controller** + +Create `apps/server/apps/gateway/src/im/messages/forwards/forwards.controller.ts`: + +```ts +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + UseGuards, +} from "@nestjs/common"; +import { AuthGuard, CurrentUser } from "@team9/auth"; +import { ForwardsService } from "./forwards.service.js"; +import { CreateForwardDto } from "./dto/create-forward.dto.js"; +import type { MessageResponse } from "../messages.service.js"; +import type { ForwardItemResponse } from "./types.js"; + +@Controller({ path: "im", version: "1" }) +@UseGuards(AuthGuard) +export class ForwardsController { + constructor(private readonly forwardsService: ForwardsService) {} + + @Post("channels/:targetChannelId/forward") + async forward( + @CurrentUser("sub") userId: string, + @Param("targetChannelId", ParseUUIDPipe) targetChannelId: string, + @Body() dto: CreateForwardDto, + ): Promise { + return this.forwardsService.forward({ + targetChannelId, + sourceChannelId: dto.sourceChannelId, + sourceMessageIds: dto.sourceMessageIds, + clientMsgId: dto.clientMsgId, + userId, + }); + } + + @Get("messages/:id/forward-items") + async getItems( + @CurrentUser("sub") userId: string, + @Param("id", ParseUUIDPipe) messageId: string, + ): Promise { + return this.forwardsService.getForwardItems(messageId, userId); + } +} +``` + +- [ ] **Step 3: Register in `MessagesModule`** + +Edit `apps/server/apps/gateway/src/im/messages/messages.module.ts`: + +```ts +import { ForwardsController } from "./forwards/forwards.controller.js"; +import { ForwardsService } from "./forwards/forwards.service.js"; + +@Module({ + imports: [ + /* existing */ + ], + controllers: [, /* existing */ ForwardsController], + providers: [, /* existing */ ForwardsService], + exports: [, /* existing */ ForwardsService], +}) +export class MessagesModule {} +``` + +(Match the file's exact existing decorator shape — likely already has `controllers` and `providers` arrays. Append, don't replace.) + +- [ ] **Step 4: Write controller spec** + +Create `forwards.controller.spec.ts`: + +```ts +import { Test } from "@nestjs/testing"; +import { ForwardsController } from "./forwards.controller.js"; +import { ForwardsService } from "./forwards.service.js"; +import { AuthGuard } from "@team9/auth"; + +describe("ForwardsController", () => { + let controller: ForwardsController; + let svc: jest.Mocked; + + beforeEach(async () => { + const m = await Test.createTestingModule({ + controllers: [ForwardsController], + providers: [ + { + provide: ForwardsService, + useValue: { forward: jest.fn(), getForwardItems: jest.fn() }, + }, + ], + }) + .overrideGuard(AuthGuard) + .useValue({ canActivate: () => true }) + .compile(); + controller = m.get(ForwardsController); + svc = m.get(ForwardsService); + }); + + it("POST forward delegates to service", async () => { + svc.forward.mockResolvedValueOnce({ id: "m1", type: "forward" } as any); + const res = await controller.forward("u-1", "ch-target", { + sourceChannelId: "ch-src", + sourceMessageIds: ["m-a"], + clientMsgId: "cid", + }); + expect(svc.forward).toHaveBeenCalledWith({ + targetChannelId: "ch-target", + sourceChannelId: "ch-src", + sourceMessageIds: ["m-a"], + clientMsgId: "cid", + userId: "u-1", + }); + expect(res.id).toBe("m1"); + }); + + it("GET items delegates to service", async () => { + svc.getForwardItems.mockResolvedValueOnce([] as any); + const res = await controller.getItems("u-1", "msg-1"); + expect(svc.getForwardItems).toHaveBeenCalledWith("msg-1", "u-1"); + expect(res).toEqual([]); + }); +}); +``` + +- [ ] **Step 5: Run tests** + +```bash +pnpm --filter @team9/server test -- forwards.controller +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add apps/server/apps/gateway/src/im/messages/forwards/ \ + apps/server/apps/gateway/src/im/messages/messages.module.ts +git commit -m "feat(im): add forward REST endpoints" +``` + +--- + +## Task 5: `MessagesService` — hydrate `forward` field on reads + +**Goal:** When a message of `type === 'forward'` is loaded by `getMessageWithDetails` or any of its bulk siblings, attach a `forward: ForwardPayload` field. Update the response interface so the client receives it on `GET /messages/:id`, `GET /channels/:id/messages`, and `GET /messages/:id/thread`. Also reject `PATCH /messages/:id` when the target is a forward message. + +**Files:** + +- Modify: `apps/server/apps/gateway/src/im/messages/messages.service.ts` +- Modify: `apps/server/apps/gateway/src/im/messages/messages.service.spec.ts` +- Modify: `apps/server/apps/gateway/src/im/messages/messages.controller.ts` (PATCH guard) +- Modify: `apps/server/apps/gateway/src/im/messages/messages.controller.spec.ts` + +**Acceptance Criteria:** + +- [ ] `MessageResponse` type includes optional `forward?: ForwardPayload`. +- [ ] For `type === 'forward'` rows, `getMessageWithDetails` populates `forward` via `ForwardsService.hydratePayload`. +- [ ] Bulk paths (`getChannelMessages`, `getChannelMessagesPaginated`, `getThread`, `getSubReplies`) hydrate forwards in batch — one query per page, not per message. +- [ ] `truncateForPreview` returns the message untouched for `type === 'forward'` (digest is already short; relational items are not preview-truncated). +- [ ] `PATCH /messages/:id` returns 400 `forward.editDisabled` when target is a forward. +- [ ] All existing message-fetch tests still pass. +- [ ] New tests cover the hydration branch and the PATCH rejection. + +**Verify:** + +```bash +pnpm --filter @team9/server test -- messages.service messages.controller +``` + +**Steps:** + +- [ ] **Step 1: Add `forward?` to the response type** + +In `messages.service.ts`, wherever `MessageResponse` is declared, add: + +```ts +import type { ForwardPayload } from "./forwards/types.js"; + +export interface MessageResponse { + // ...existing fields + forward?: ForwardPayload; +} +``` + +- [ ] **Step 2: Inject `ForwardsService` into `MessagesService`** + +Use `@Inject(forwardRef(...))` (mirrors how `MessagesController` already pulls `WebsocketGateway`). Then in `getMessageWithDetails`, after building the base response: + +```ts +if (message.type === "forward") { + const meta = (message.metadata ?? {}) as { forward?: ForwardMetadata }; + if (meta.forward) { + response.forward = await this.forwardsService.hydratePayload( + message.id, + requesterUserId, + meta.forward, + ); + } +} +``` + +`requesterUserId` must thread through. If `getMessageWithDetails` doesn't currently take a `userId`, add an optional `userId?: string` parameter and update callers (controller already has `userId`). Pass undefined → `canJumpToOriginal` defaults to `false` for the row (treat unknown user as no access). + +- [ ] **Step 3: Bulk hydration** + +Add a private helper: + +```ts +private async hydrateForwardsBatch( + messages: MessageResponse[], + userId: string | undefined, +): Promise { + const fwdMessages = messages.filter((m) => m.type === 'forward'); + if (fwdMessages.length === 0 || !userId) return; + await Promise.all(fwdMessages.map(async (m) => { + const meta = (m.metadata ?? {}) as { forward?: ForwardMetadata }; + if (meta.forward) { + m.forward = await this.forwardsService.hydratePayload(m.id, userId, meta.forward); + } + })); +} +``` + +Call it inside the bulk fetch methods after assembling the array. (Per-message Promise.all is fine — V1 expects ≤ 50 messages per page; we can batch into a single SQL fetch later if it shows up in profiling.) + +- [ ] **Step 4: Pass `userId` through controller calls** + +In `messages.controller.ts`, every place that calls `getMessageWithDetails`, `getChannelMessages`, `getChannelMessagesPaginated`, `getThread`, `getSubReplies`, pass the current `userId`. Update the service signatures accordingly. + +- [ ] **Step 5: Reject PATCH on forward type** + +In `messages.controller.ts:updateMessage`, before calling `messagesService.update`, fetch the existing message type: + +```ts +const existing = await this.messagesService.getMessageWithDetails(messageId); +if (existing.type === "forward") { + throw new BadRequestException("forward.editDisabled"); +} +``` + +(If the existing service `update` already loads the row first, you can move the check inside `MessagesService.update` to avoid a double-fetch — pick whichever matches the file's existing pattern.) + +- [ ] **Step 6: Tests** + +In `messages.service.spec.ts`: + +```ts +describe("forward hydration", () => { + it("attaches forward payload for type=forward messages", async () => { + forwardsService.hydratePayload.mockResolvedValueOnce({ + kind: "single", + count: 1, + items: [], + } as any); + const m = await service.getMessageWithDetails("m-fwd", "u-1"); + expect(m.forward?.kind).toBe("single"); + }); + + it("skips hydration for non-forward messages", async () => { + const m = await service.getMessageWithDetails("m-text", "u-1"); + expect(m.forward).toBeUndefined(); + expect(forwardsService.hydratePayload).not.toHaveBeenCalled(); + }); + + it("hydrates each forward in a paginated page", async () => { + forwardsService.hydratePayload.mockResolvedValue({ + kind: "single", + count: 1, + items: [], + } as any); + const page = await service.getChannelMessagesPaginated( + "ch-1", + 50, + {}, + "u-1", + ); + const fwdCount = page.messages.filter((m) => m.type === "forward").length; + expect(forwardsService.hydratePayload).toHaveBeenCalledTimes(fwdCount); + }); +}); +``` + +In `messages.controller.spec.ts`: + +```ts +it("rejects PATCH on forward-type message", async () => { + messagesService.getMessageWithDetails.mockResolvedValueOnce({ + id: "m", + type: "forward", + } as any); + await expect( + controller.updateMessage("u-1", "m", { content: "x" }), + ).rejects.toThrow("forward.editDisabled"); +}); +``` + +- [ ] **Step 7: Run tests** + +```bash +pnpm --filter @team9/server test -- messages.service messages.controller +``` + +Expected: PASS, including all pre-existing tests. + +- [ ] **Step 8: Commit** + +```bash +git add apps/server/apps/gateway/src/im/messages/ +git commit -m "feat(im): hydrate forward payload on message reads; reject PATCH on forwards" +``` + +--- + +## Task 6: Backend e2e — `forward.e2e-spec.ts` + +**Goal:** End-to-end coverage that exercises the full request → DB → WS broadcast → read-back loop, mirroring the scenarios in spec §9.2. Uses the existing gateway e2e harness (Postgres + Redis + RabbitMQ via Docker as already configured). + +**Files:** + +- Create: `apps/server/apps/gateway/test/forward.e2e-spec.ts` + +**Acceptance Criteria:** + +- [ ] Two-channel happy path: post 3 messages in A, forward all 3 to B as a bundle, assert WS `new_message` is observed by a B subscriber, assert `GET /messages/:id` returns hydrated `forward.items.length === 3`, assert `GET /messages/:id/forward-items` returns the same 3. +- [ ] Single-image forward: assert `im_message_attachments` row count for the new forward message is `0`, assert `forward.items[0].attachmentsSnapshot[0].fileUrl` matches original. +- [ ] Forward to a channel where you have read but no write → 403. +- [ ] Forward from a channel you can't read → 403. +- [ ] Re-forward chain: forward a forward into channel C, assert chain depth 1. +- [ ] Soft-deleted source after forwarding: `forward-items` still renders snapshot, `canJumpToOriginal === false`. +- [ ] Sending >100 in `sourceMessageIds` → 400 `forward.tooManySelected`. +- [ ] Mixed-channel selection → 400 `forward.mixedChannels`. + +**Verify:** + +```bash +pnpm --filter @team9/server test:e2e -- forward +``` + +**Steps:** + +- [ ] **Step 1: Read existing e2e harness** + +```bash +ls apps/server/apps/gateway/test/ +head -60 apps/server/apps/gateway/test/messages.e2e-spec.ts 2>/dev/null \ + || head -60 apps/server/apps/gateway/test/im.e2e-spec.ts 2>/dev/null +``` + +Identify the existing fixture helpers (login, create-channel, post-message, WS-connect). Mirror their style. Do not invent a parallel harness. + +- [ ] **Step 2: Scaffold `forward.e2e-spec.ts`** + +Create the file using the existing harness's `beforeAll`/`afterAll` shape. Provide: + +```ts +describe("Forward e2e", () => { + let app: INestApplication; + let userA: { id: string; token: string }; + let userB: { id: string; token: string }; + let chSource: string; + let chTarget: string; + + beforeAll(async () => { + app = await bootstrapTestApp(); // existing helper + userA = await registerAndLogin(app, "a@x.test"); + userB = await registerAndLogin(app, "b@x.test"); + chSource = await createChannel(app, userA.token, { + name: "src", + type: "public", + }); + chTarget = await createChannel(app, userA.token, { + name: "dst", + type: "public", + }); + await joinChannel(app, userB.token, chSource); + await joinChannel(app, userB.token, chTarget); + }); + afterAll(async () => { + await app.close(); + }); + + // ...individual `it(...)` blocks for each scenario above... +}); +``` + +- [ ] **Step 3: Implement each scenario block** + +For each scenario in Acceptance Criteria, write an `it(...)` that: + +1. Posts whatever fixture messages it needs. +2. Calls `POST /api/v1/im/channels/:targetChannelId/forward` with the right body. +3. Asserts response shape, then re-fetches via `GET /messages/:id` to assert hydration. +4. For the WS test: connect a Socket.io client as user B before calling forward, await the `new_message` event with a 2s timeout, assert `payload.type === 'forward'`. + +For the soft-delete scenario, hit `DELETE /messages/:sourceId` between the forward and the read-back, then assert `forward.items[0].canJumpToOriginal === false` and `contentSnapshot` is still populated. + +For the attachment scenario, use the existing image-upload helper (or post a message with `attachments: [{ fileName, fileUrl, ... }]` in the create-message DTO if the harness supports synthesized attachments). After forwarding, query `im_message_attachments WHERE message_id = $forwardId` directly via the test DB connection and assert the count is 0. + +- [ ] **Step 4: Run the suite** + +```bash +pnpm --filter @team9/server test:e2e -- forward +``` + +Expected: all `it(...)` blocks pass. Iterate on flakiness (e.g. WS race) by extending timeouts or awaiting an explicit `socket.connected === true` before triggering the forward. + +- [ ] **Step 5: Commit** + +```bash +git add apps/server/apps/gateway/test/forward.e2e-spec.ts +git commit -m "test(im): e2e coverage for forward + bundle + re-forward" +``` + +--- + +## Task 7: Frontend — types, API client, selection store + +**Goal:** Land the type extensions, the two new API methods, and the Zustand store that drives selection mode. No UI yet. After this task, the data plumbing is ready for the components in Tasks 8–11. + +**Files:** + +- Modify: `apps/client/src/types/im.ts` +- Modify: `apps/client/src/services/api.ts` +- Create: `apps/client/src/stores/useForwardSelectionStore.ts` +- Create: `apps/client/src/stores/__tests__/useForwardSelectionStore.test.ts` + +**Acceptance Criteria:** + +- [ ] `MessageType` union includes `'forward'`. +- [ ] `Message` interface includes optional `forward?: ForwardPayload`. +- [ ] `ForwardPayload`, `ForwardItem`, `ForwardAttachmentSnapshot` exported from `@/types/im`. +- [ ] `api.forward.create({ targetChannelId, sourceChannelId, sourceMessageIds, clientMsgId? })` posts to the right URL and returns `Message`. +- [ ] `api.forward.getItems(messageId)` returns `ForwardItem[]`. +- [ ] Selection store: `enter(channelId)`, `exit()`, `toggle(messageId)`, `addRange(messageIds)`, `clear()`. Cap enforcement (`add` past 100 returns false and emits no state change). Switching `channelId` clears `selectedIds`. Store unit-tested at 100% coverage. + +**Verify:** + +```bash +pnpm --filter @team9/client test -- useForwardSelectionStore +pnpm --filter @team9/client lint +``` + +**Steps:** + +- [ ] **Step 1: Extend types** + +Edit `apps/client/src/types/im.ts:12-18`: + +```ts +export type MessageType = + | "text" + | "file" + | "image" + | "system" + | "tracking" + | "long_text" + | "forward"; +``` + +Append to the same file (after the existing exports): + +```ts +export interface ForwardAttachmentSnapshot { + originalAttachmentId: string; + fileName: string; + fileUrl: string; + fileKey: string | null; + fileSize: number; + mimeType: string; + thumbnailUrl: string | null; + width: number | null; + height: number | null; +} + +export interface ForwardItem { + position: number; + sourceMessageId: string | null; + sourceChannelId: string; + sourceChannelName: string | null; + sourceWorkspaceId: string | null; + sourceSender: { + id: string; + username: string; + displayName: string | null; + avatarUrl: string | null; + } | null; + sourceCreatedAt: string; + sourceSeqId: string | null; + sourceType: "text" | "long_text" | "file" | "image" | "forward"; + contentSnapshot: string | null; + contentAstSnapshot: Record | null; + attachmentsSnapshot: ForwardAttachmentSnapshot[]; + canJumpToOriginal: boolean; + truncated: boolean; +} + +export interface ForwardPayload { + kind: "single" | "bundle"; + count: number; + sourceChannelId: string; + sourceChannelName: string | null; + truncated: boolean; + items: ForwardItem[]; +} +``` + +Find the `Message` interface (around line 199) and add: + +```ts +forward?: ForwardPayload; +``` + +- [ ] **Step 2: Add API methods** + +In `apps/client/src/services/api.ts`, append a `forward` namespace: + +```ts +export const forwardApi = { + async create(input: { + targetChannelId: string; + sourceChannelId: string; + sourceMessageIds: string[]; + clientMsgId?: string; + }): Promise { + return http.post( + `/api/v1/im/channels/${input.targetChannelId}/forward`, + { + sourceChannelId: input.sourceChannelId, + sourceMessageIds: input.sourceMessageIds, + clientMsgId: input.clientMsgId, + }, + ); + }, + async getItems(messageId: string): Promise { + return http.get( + `/api/v1/im/messages/${messageId}/forward-items`, + ); + }, +}; +``` + +Match the file's existing pattern — if `api.ts` exports a single object (e.g. `export const api = { ... }`), nest `forward: forwardApi` inside it. If it exports per-feature consts, follow that style. + +- [ ] **Step 3: Write the selection store** + +Create `apps/client/src/stores/useForwardSelectionStore.ts`: + +```ts +import { create } from "zustand"; + +const MAX_SELECTED = 100; + +interface ForwardSelectionState { + active: boolean; + channelId: string | null; + selectedIds: Set; + enter: (channelId: string) => void; + exit: () => void; + toggle: (messageId: string) => boolean; // returns true on success, false when capped + addRange: (messageIds: string[]) => number; // returns count actually added + clear: () => void; + isSelected: (messageId: string) => boolean; +} + +export const useForwardSelectionStore = create( + (set, get) => ({ + active: false, + channelId: null, + selectedIds: new Set(), + enter: (channelId) => + set({ active: true, channelId, selectedIds: new Set() }), + exit: () => set({ active: false, channelId: null, selectedIds: new Set() }), + toggle: (messageId) => { + const state = get(); + if (!state.active) return false; + const next = new Set(state.selectedIds); + if (next.has(messageId)) { + next.delete(messageId); + set({ selectedIds: next }); + return true; + } + if (next.size >= MAX_SELECTED) return false; + next.add(messageId); + set({ selectedIds: next }); + return true; + }, + addRange: (messageIds) => { + const state = get(); + if (!state.active) return 0; + const next = new Set(state.selectedIds); + let added = 0; + for (const id of messageIds) { + if (next.size >= MAX_SELECTED) break; + if (!next.has(id)) { + next.add(id); + added += 1; + } + } + set({ selectedIds: next }); + return added; + }, + clear: () => set({ selectedIds: new Set() }), + isSelected: (messageId) => get().selectedIds.has(messageId), + }), +); + +export const FORWARD_SELECTION_MAX = MAX_SELECTED; +``` + +- [ ] **Step 4: Test the store** + +Create `apps/client/src/stores/__tests__/useForwardSelectionStore.test.ts`: + +```ts +import { describe, it, expect, beforeEach } from "vitest"; +import { + useForwardSelectionStore, + FORWARD_SELECTION_MAX, +} from "../useForwardSelectionStore"; + +beforeEach(() => { + useForwardSelectionStore.getState().exit(); +}); + +describe("useForwardSelectionStore", () => { + it("enters mode for a channel", () => { + useForwardSelectionStore.getState().enter("ch-1"); + expect(useForwardSelectionStore.getState().active).toBe(true); + expect(useForwardSelectionStore.getState().channelId).toBe("ch-1"); + }); + + it("exit resets state", () => { + useForwardSelectionStore.getState().enter("ch-1"); + useForwardSelectionStore.getState().toggle("m-1"); + useForwardSelectionStore.getState().exit(); + expect(useForwardSelectionStore.getState().active).toBe(false); + expect(useForwardSelectionStore.getState().selectedIds.size).toBe(0); + }); + + it("toggle adds and removes ids", () => { + useForwardSelectionStore.getState().enter("ch-1"); + expect(useForwardSelectionStore.getState().toggle("m-1")).toBe(true); + expect(useForwardSelectionStore.getState().isSelected("m-1")).toBe(true); + expect(useForwardSelectionStore.getState().toggle("m-1")).toBe(true); + expect(useForwardSelectionStore.getState().isSelected("m-1")).toBe(false); + }); + + it("toggle returns false when inactive", () => { + expect(useForwardSelectionStore.getState().toggle("m-1")).toBe(false); + }); + + it("toggle enforces cap", () => { + useForwardSelectionStore.getState().enter("ch-1"); + for (let i = 0; i < FORWARD_SELECTION_MAX; i += 1) { + useForwardSelectionStore.getState().toggle(`m-${i}`); + } + expect(useForwardSelectionStore.getState().toggle("m-overflow")).toBe( + false, + ); + expect(useForwardSelectionStore.getState().selectedIds.size).toBe( + FORWARD_SELECTION_MAX, + ); + }); + + it("addRange respects cap and returns added count", () => { + useForwardSelectionStore.getState().enter("ch-1"); + const ids = Array.from({ length: 150 }, (_, i) => `m-${i}`); + const added = useForwardSelectionStore.getState().addRange(ids); + expect(added).toBe(FORWARD_SELECTION_MAX); + expect(useForwardSelectionStore.getState().selectedIds.size).toBe( + FORWARD_SELECTION_MAX, + ); + }); + + it("addRange returns 0 when inactive", () => { + expect(useForwardSelectionStore.getState().addRange(["m-1"])).toBe(0); + }); + + it("clear empties selection without exiting", () => { + useForwardSelectionStore.getState().enter("ch-1"); + useForwardSelectionStore.getState().toggle("m-1"); + useForwardSelectionStore.getState().clear(); + expect(useForwardSelectionStore.getState().selectedIds.size).toBe(0); + expect(useForwardSelectionStore.getState().active).toBe(true); + }); + + it("entering a different channel clears selection", () => { + useForwardSelectionStore.getState().enter("ch-1"); + useForwardSelectionStore.getState().toggle("m-1"); + useForwardSelectionStore.getState().enter("ch-2"); + expect(useForwardSelectionStore.getState().channelId).toBe("ch-2"); + expect(useForwardSelectionStore.getState().selectedIds.size).toBe(0); + }); +}); +``` + +- [ ] **Step 5: Run tests** + +```bash +pnpm --filter @team9/client test -- useForwardSelectionStore +``` + +Expected: 9 specs pass at 100% coverage on the store file. + +- [ ] **Step 6: Commit** + +```bash +git add apps/client/src/types/im.ts \ + apps/client/src/services/api.ts \ + apps/client/src/stores/useForwardSelectionStore.ts \ + apps/client/src/stores/__tests__/useForwardSelectionStore.test.ts +git commit -m "feat(client): add forward types, API methods, selection store" +``` + +--- + +## Task 8: `ForwardDialog` + channel picker + preview + +**Goal:** Build the modal that lets the user pick a target channel and confirm the forward. Used by both single-message (hover toolbar / context menu) and multi-select (selection action bar) flows. Triggers `api.forward.create(...)` and closes on success. + +**Files:** + +- Create: `apps/client/src/components/channel/forward/ForwardDialog.tsx` +- Create: `apps/client/src/components/channel/forward/ForwardChannelList.tsx` +- Create: `apps/client/src/components/channel/forward/ForwardPreview.tsx` +- Create: `apps/client/src/components/channel/forward/__tests__/ForwardDialog.test.tsx` +- Create: `apps/client/src/components/channel/forward/__tests__/ForwardChannelList.test.tsx` +- Create: `apps/client/src/components/channel/forward/__tests__/ForwardPreview.test.tsx` + +**Acceptance Criteria:** + +- [ ] `ForwardDialog` accepts `{ open, onOpenChange, sourceChannelId, sourceMessages: Message[] }`. Shows single-quote preview when `sourceMessages.length === 1`, bundle preview otherwise. +- [ ] Channel list excludes archived/deactivated channels and the source channel itself (UX hint, not a hard block — server still validates). +- [ ] Search box filters channels by name (case-insensitive substring). +- [ ] Confirm button is disabled until a channel is selected; shows spinner while the API call is in flight. +- [ ] On success: closes dialog, shows success toast (`forward.success` — add this i18n key in this task), invalidates the destination channel's message-list query. +- [ ] On error: shows error toast with the server's error code mapped to an i18n string. +- [ ] All three component tests at 100% line + branch coverage. + +**Verify:** + +```bash +pnpm --filter @team9/client test -- forward/__tests__ +``` + +**Steps:** + +- [ ] **Step 1: Add the missing i18n key** + +In both `en/channel.json` and `zh-CN/channel.json`, add under the existing `forward` block: + +```json +"success": "Forwarded.", // en +"success": "已转发" // zh-CN +``` + +- [ ] **Step 2: Build `ForwardChannelList.tsx`** + +```tsx +import { useState, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useChannels } from "@/hooks/useChannels"; // or whichever existing hook +import type { Channel } from "@/types/im"; + +interface Props { + excludeChannelId?: string; + selectedChannelId: string | null; + onSelect: (channelId: string) => void; +} + +export function ForwardChannelList({ + excludeChannelId, + selectedChannelId, + onSelect, +}: Props) { + const { t } = useTranslation("channel"); + const { data: channels = [] } = useChannels(); + const [query, setQuery] = useState(""); + + const filtered = useMemo(() => { + return channels.filter((c) => { + if (c.id === excludeChannelId) return false; + if (c.isArchived) return false; + if (c.isActivated === false) return false; + if (!query) return true; + return c.name.toLowerCase().includes(query.toLowerCase()); + }); + }, [channels, query, excludeChannelId]); + + return ( +
+ setQuery(e.target.value)} + placeholder={t("forward.dialog.searchPlaceholder")} + className="w-full rounded border px-3 py-2" + aria-label={t("forward.dialog.searchPlaceholder")} + /> +
    + {filtered.map((c) => ( +
  • onSelect(c.id)} + className={`cursor-pointer px-3 py-2 hover:bg-accent ${selectedChannelId === c.id ? "bg-accent" : ""}`} + > + #{c.name} +
  • + ))} +
+
+ ); +} +``` + +If the project's existing channel hook is named differently (e.g. `useWorkspaceChannels`), substitute. Verify by grepping `apps/client/src/hooks/`. + +- [ ] **Step 3: Build `ForwardPreview.tsx`** + +```tsx +import { useTranslation } from "react-i18next"; +import type { Message } from "@/types/im"; +import { UserAvatar } from "@/components/ui/user-avatar"; + +interface Props { + messages: Message[]; +} + +export function ForwardPreview({ messages }: Props) { + const { t } = useTranslation("channel"); + if (messages.length === 1) { + const m = messages[0]; + return ( +
+
+ + {m.sender?.displayName ?? m.sender?.username} +
+
+ {m.content} +
+
+ ); + } + return ( +
+
+ {t("forward.bundle.title", { count: messages.length })} +
+
    + {messages.slice(0, 3).map((m) => ( +
  • + + + {m.sender?.displayName ?? m.sender?.username} + + + {m.content?.slice(0, 80)} + +
  • + ))} + {messages.length > 3 && ( +
  • + …{t("forward.bundle.viewAll")} +
  • + )} +
+
+ ); +} +``` + +- [ ] **Step 4: Build `ForwardDialog.tsx`** + +```tsx +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { toast } from "@/components/ui/use-toast"; // or whatever toast util exists +import { forwardApi } from "@/services/api"; +import { ForwardChannelList } from "./ForwardChannelList"; +import { ForwardPreview } from "./ForwardPreview"; +import type { Message } from "@/types/im"; + +const ERROR_TO_KEY: Record = { + "forward.tooManySelected": "forward.tooManySelected", + "forward.mixedChannels": "forward.error.mixedChannels", + "forward.noWriteAccess": "forward.error.noWriteAccess", + "forward.noSourceAccess": "forward.error.noSourceAccess", + "forward.notAllowed": "forward.error.notAllowed", + "forward.notFound": "forward.error.notFound", + "forward.empty": "forward.error.empty", +}; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + sourceChannelId: string; + sourceMessages: Message[]; + onSuccess?: () => void; +} + +export function ForwardDialog({ + open, + onOpenChange, + sourceChannelId, + sourceMessages, + onSuccess, +}: Props) { + const { t } = useTranslation("channel"); + const queryClient = useQueryClient(); + const [targetChannelId, setTargetChannelId] = useState(null); + + const mutation = useMutation({ + mutationFn: () => { + if (!targetChannelId) throw new Error("no target"); + return forwardApi.create({ + targetChannelId, + sourceChannelId, + sourceMessageIds: sourceMessages.map((m) => m.id), + }); + }, + onSuccess: (_msg, _vars) => { + toast({ description: t("forward.success") }); + if (targetChannelId) { + queryClient.invalidateQueries({ + queryKey: ["channelMessages", targetChannelId], + }); + } + setTargetChannelId(null); + onOpenChange(false); + onSuccess?.(); + }, + onError: (err: unknown) => { + const code = err instanceof Error ? err.message : String(err); + const key = ERROR_TO_KEY[code] ?? "forward.error.notAllowed"; + toast({ description: t(key), variant: "destructive" }); + }, + }); + + const title = + sourceMessages.length === 1 + ? t("forward.dialog.titleSingle") + : t("forward.dialog.titleBundle", { count: sourceMessages.length }); + + return ( + + + + {title} + +
+ + +
+ + + + +
+
+ ); +} +``` + +If the project's HTTP client surfaces the server's error string differently (e.g. the body's `message` field on the response), inspect `services/http.ts` to confirm and adjust the `onError` extraction. + +- [ ] **Step 5: Tests** + +For each component, write a vitest + @testing-library/react file. Example for `ForwardDialog`: + +```tsx +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ForwardDialog } from "../ForwardDialog"; +import { forwardApi } from "@/services/api"; + +vi.mock("@/services/api", () => ({ + forwardApi: { create: vi.fn(), getItems: vi.fn() }, +})); +vi.mock("@/hooks/useChannels", () => ({ + useChannels: () => ({ + data: [ + { + id: "ch-1", + name: "general", + type: "public", + isArchived: false, + isActivated: true, + }, + { + id: "ch-2", + name: "src", + type: "public", + isArchived: false, + isActivated: true, + }, + ], + }), +})); + +function wrap(ui: React.ReactNode) { + const qc = new QueryClient(); + return render({ui}); +} + +describe("ForwardDialog", () => { + beforeEach(() => vi.clearAllMocks()); + + it("disables confirm until a channel is selected", () => { + wrap( + {}} + sourceChannelId="ch-2" + sourceMessages={[{ id: "m-1", content: "hi" } as any]} + />, + ); + expect(screen.getByRole("button", { name: /forward/i })).toBeDisabled(); + }); + + it("calls API on confirm and closes on success", async () => { + (forwardApi.create as any).mockResolvedValueOnce({ id: "new-msg" }); + const onOpenChange = vi.fn(); + wrap( + , + ); + fireEvent.click(screen.getByRole("option", { name: /general/i })); + fireEvent.click(screen.getByRole("button", { name: /forward/i })); + await waitFor(() => + expect(forwardApi.create).toHaveBeenCalledWith({ + targetChannelId: "ch-1", + sourceChannelId: "ch-2", + sourceMessageIds: ["m-1"], + }), + ); + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false)); + }); + + it("shows error toast on API failure", async () => { + (forwardApi.create as any).mockRejectedValueOnce( + new Error("forward.noWriteAccess"), + ); + wrap( + {}} + sourceChannelId="ch-2" + sourceMessages={[{ id: "m-1", content: "hi" } as any]} + />, + ); + fireEvent.click(screen.getByRole("option", { name: /general/i })); + fireEvent.click(screen.getByRole("button", { name: /forward/i })); + await waitFor(() => expect(forwardApi.create).toHaveBeenCalled()); + }); +}); +``` + +For `ForwardChannelList`: assert search filters, archived/deactivated/excluded channels are hidden. + +For `ForwardPreview`: assert single render + bundle render (with truncation indicator when >3). + +- [ ] **Step 6: Run tests + coverage** + +```bash +pnpm --filter @team9/client test -- forward/__tests__ --coverage +``` + +Expected: 100% on the three new files. + +- [ ] **Step 7: Commit** + +```bash +git add apps/client/src/components/channel/forward/ \ + apps/client/src/i18n/locales/ +git commit -m "feat(client): add ForwardDialog with channel picker and preview" +``` + +--- + +## Task 9: `ForwardedMessageCard` + `ForwardBundleViewer` + +**Goal:** Render forward messages on the receiving end. Quote card for single, stacked bundle card for multi (with click-to-expand modal). + +**Files:** + +- Create: `apps/client/src/components/channel/forward/ForwardedMessageCard.tsx` +- Create: `apps/client/src/components/channel/forward/ForwardBundleViewer.tsx` +- Create: `apps/client/src/components/channel/forward/__tests__/ForwardedMessageCard.test.tsx` +- Create: `apps/client/src/components/channel/forward/__tests__/ForwardBundleViewer.test.tsx` + +**Acceptance Criteria:** + +- [ ] `ForwardedMessageCard` branches on `message.forward.kind`. Single → quote-style card. Bundle → stacked card with header + first 3 previews + "View all". +- [ ] Both render the "Forwarded from #X" header (or "Source no longer available" when `sourceChannelName` is null and `canJumpToOriginal` is false everywhere). +- [ ] Single quote card supports clicking through to the original (via `Jump to original` link) when `canJumpToOriginal === true`. The link uses the existing message-deep-link route (pattern: `/{workspaceSlug}/channel/{channelId}?message={messageId}`). +- [ ] Bundle card opens `ForwardBundleViewer` modal on click; modal lazy-fetches full items via `forwardApi.getItems(messageId)`. +- [ ] Modal renders all items in `position` order: original sender header + relative timestamp + content (Lexical when `contentAstSnapshot` is non-null, plaintext otherwise) + attachment chips. +- [ ] Both components tested at 100%. + +**Verify:** + +```bash +pnpm --filter @team9/client test -- ForwardedMessageCard ForwardBundleViewer +``` + +**Steps:** + +- [ ] **Step 1: Inspect existing renderer for AST and attachments** + +```bash +grep -n "contentAst\|MessageAttachments\|AstRenderer" apps/client/src/components/channel/MessageContent.tsx +``` + +Find how `MessageContent` dispatches between AST and HTML/Markdown, and how attachments are rendered. Reuse those primitives (likely `` and ``) inside the forward card so styling stays consistent. + +- [ ] **Step 2: Build `ForwardedMessageCard.tsx`** + +```tsx +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "@tanstack/react-router"; +import type { Message, ForwardItem } from "@/types/im"; +import { UserAvatar } from "@/components/ui/user-avatar"; +import { AstRenderer } from "../AstRenderer"; +import { ForwardBundleViewer } from "./ForwardBundleViewer"; + +interface Props { + message: Message; +} + +export function ForwardedMessageCard({ message }: Props) { + const { t } = useTranslation("channel"); + const navigate = useNavigate(); + const [bundleOpen, setBundleOpen] = useState(false); + + const fwd = message.forward; + if (!fwd) return null; + + const headerText = fwd.sourceChannelName + ? t("forward.card.fromChannel", { channelName: fwd.sourceChannelName }) + : t("forward.source.unavailable"); + + if (fwd.kind === "single") { + const item = fwd.items[0]; + return ( +
+
{headerText}
+
+ + {item.canJumpToOriginal && item.sourceMessageId && ( + + )} +
+
+ ); + } + + const previews = fwd.items.slice(0, 3); + return ( + <> +
+
{headerText}
+ +
+ {bundleOpen && ( + + )} + + ); +} + +function ForwardItemBody({ item }: { item: ForwardItem }) { + return ( + <> +
+ + + {item.sourceSender?.displayName ?? item.sourceSender?.username ?? "?"} + + + {new Date(item.sourceCreatedAt).toLocaleString()} + +
+
+ {item.contentAstSnapshot ? ( + + ) : ( + + {item.contentSnapshot ?? ""} + + )} +
+ {item.attachmentsSnapshot.length > 0 && ( + + )} + + ); +} +``` + +- [ ] **Step 3: Build `ForwardBundleViewer.tsx`** + +```tsx +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { forwardApi } from "@/services/api"; +import { ForwardItemBodyExport as ForwardItemBody } from "./ForwardedMessageCard"; +// (export ForwardItemBody from ForwardedMessageCard.tsx for reuse, or duplicate the small render block here) + +interface Props { + messageId: string; + channelName: string | null; + onOpenChange: (open: boolean) => void; +} + +export function ForwardBundleViewer({ + messageId, + channelName, + onOpenChange, +}: Props) { + const { t } = useTranslation("channel"); + const { data, isLoading, isError } = useQuery({ + queryKey: ["forwardItems", messageId], + queryFn: () => forwardApi.getItems(messageId), + }); + + return ( + + + + + {channelName + ? t("forward.bundle.modalTitle", { channelName }) + : t("forward.source.unavailable")} + + + {isLoading && ( +
+ )} + {isError && ( +
+ {t("forward.error.notFound")} +
+ )} + {data && ( +
    + {data.map((item) => ( +
  • + +
  • + ))} +
+ )} +
+
+ ); +} +``` + +(Export `ForwardItemBody` from `ForwardedMessageCard.tsx` so this file can reuse it; alternatively, lift it into its own `ForwardItemBody.tsx` — pick whichever keeps the diff small.) + +- [ ] **Step 4: Tests** + +`ForwardedMessageCard.test.tsx`: + +- Single forward renders sender + content + jump link when `canJumpToOriginal`. +- Single forward hides jump link when `canJumpToOriginal === false`. +- Bundle forward renders preview rows + "View all" indicator when count > 3. +- Bundle click opens viewer (assert `forwardApi.getItems` is called). +- Source unavailable header shown when `sourceChannelName === null`. + +`ForwardBundleViewer.test.tsx`: + +- Loading state, success state (renders all items), error state. +- Renders attachments as links. + +```bash +pnpm --filter @team9/client test -- ForwardedMessageCard ForwardBundleViewer --coverage +``` + +Expected: 100% on both files. + +- [ ] **Step 5: Commit** + +```bash +git add apps/client/src/components/channel/forward/ +git commit -m "feat(client): render forwarded message cards and bundle viewer" +``` + +--- + +## Task 10: `SelectionActionBar` + `MessageList` integration + `MessageItem` checkbox + +**Goal:** Wire the selection-mode UI into the channel: per-row checkboxes (with eligibility tooltips), the bottom action bar, route-change exit, Esc cancel. + +**Files:** + +- Create: `apps/client/src/components/channel/forward/SelectionActionBar.tsx` +- Create: `apps/client/src/components/channel/forward/__tests__/SelectionActionBar.test.tsx` +- Modify: `apps/client/src/components/channel/MessageItem.tsx` +- Modify: `apps/client/src/components/channel/MessageList.tsx` +- Modify: `apps/client/src/components/channel/__tests__/MessageList.test.tsx` (or create if absent) + +**Acceptance Criteria:** + +- [ ] When `useForwardSelectionStore.active === true && channelId === currentChannelId`, every eligible message row shows a checkbox; ineligible rows show a disabled checkbox with a tooltip explaining why. +- [ ] Clicking a checkbox toggles selection; clicking the row body in selection mode also toggles (and disables thread/quote/reactions). +- [ ] `Shift+click` selects the contiguous range from the previous click anchor. +- [ ] Sticky action bar appears at the bottom of the message list while in mode, showing `"{count} selected"` + Forward + Cancel. +- [ ] Forward button opens `ForwardDialog` with the selected messages preloaded (resolved via current page's message map; if a selected id is no longer in cache, drop it silently). +- [ ] Esc cancels selection mode. Switching channels (route change) calls `exit()`. +- [ ] > 100 selection attempt shows toast `forward.tooManySelected` and rejects the click. + +**Verify:** + +```bash +pnpm --filter @team9/client test -- SelectionActionBar MessageList +``` + +**Steps:** + +- [ ] **Step 1: Build `SelectionActionBar.tsx`** + +```tsx +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { useForwardSelectionStore } from "@/stores/useForwardSelectionStore"; + +interface Props { + onForward: () => void; +} + +export function SelectionActionBar({ onForward }: Props) { + const { t } = useTranslation("channel"); + const { active, selectedIds, exit } = useForwardSelectionStore(); + if (!active) return null; + return ( +
+ + {t("forward.selection.bar", { count: selectedIds.size })} + +
+ + +
+
+ ); +} +``` + +- [ ] **Step 2: Add the eligibility helper** + +Create `apps/client/src/components/channel/forward/eligibility.ts`: + +```ts +import type { Message } from "@/types/im"; + +const ALLOWED_TYPES = new Set([ + "text", + "long_text", + "file", + "image", + "forward", +]); + +export function isForwardable(message: Message): boolean { + if (message.isDeleted) return false; + if (!ALLOWED_TYPES.has(message.type)) return false; + const meta = message.metadata as Record | undefined; + if (meta?.streaming === true) return false; + return true; +} + +/** Build the range of message ids between two anchors (inclusive), filtered to forwardable. */ +export function computeForwardableRange( + visibleMessages: Message[], + fromId: string, + toId: string, +): string[] { + const fromIdx = visibleMessages.findIndex((m) => m.id === fromId); + const toIdx = visibleMessages.findIndex((m) => m.id === toId); + if (fromIdx === -1 || toIdx === -1) return []; + const [lo, hi] = fromIdx <= toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx]; + return visibleMessages + .slice(lo, hi + 1) + .filter(isForwardable) + .map((m) => m.id); +} +``` + +- [ ] **Step 3: Modify `MessageItem.tsx`** + +Read the current file end-to-end first (`MessageItem.tsx` is 580 lines). The component receives `visibleMessages: Message[]` (the current rendered page) — if the prop doesn't exist yet, add it from `MessageList` in Step 4 below. Then in `MessageItem`: + +```tsx +import { useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { useForwardSelectionStore } from "@/stores/useForwardSelectionStore"; +import { isForwardable, computeForwardableRange } from "./forward/eligibility"; +import { toast } from "@/components/ui/use-toast"; + +const { t } = useTranslation("channel"); +const selection = useForwardSelectionStore(); +const inSelectionMode = + selection.active && selection.channelId === message.channelId; +const isEligible = isForwardable(message); +const isSelected = inSelectionMode && selection.isSelected(message.id); +const lastAnchorRef = useRef(null); + +const toggleSelection = (shiftKey: boolean) => { + if (!isEligible) return; + if (shiftKey && lastAnchorRef.current) { + const range = computeForwardableRange( + visibleMessages, + lastAnchorRef.current, + message.id, + ); + const added = selection.addRange(range); + if (added < range.length) { + toast({ + description: t("forward.tooManySelected"), + variant: "destructive", + }); + } + } else { + const ok = selection.toggle(message.id); + if (!ok) + toast({ + description: t("forward.tooManySelected"), + variant: "destructive", + }); + lastAnchorRef.current = message.id; + } +}; + +const handleRowClick = (e: React.MouseEvent) => { + if (inSelectionMode) { + e.preventDefault(); + e.stopPropagation(); + toggleSelection(e.shiftKey); + return; + } + existingClickHandler?.(e); +}; +``` + +Render the checkbox on the row's left rail when `inSelectionMode`: + +```tsx +{ + inSelectionMode && ( + + toggleSelection( + e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey, + ) + } + onClick={(e) => e.stopPropagation()} + className="mr-2" + /> + ); +} +``` + +Disable the existing hover toolbar / context menu wrappers when `inSelectionMode` is true (they shouldn't fire while the user is in selection mode) by gating their render on `!inSelectionMode`. + +- [ ] **Step 4: Modify `MessageList.tsx`** + +Append the `SelectionActionBar` to the bottom of the list container. Wire its `onForward` to local state opening `ForwardDialog`: + +```tsx +const selection = useForwardSelectionStore(); +const [forwardOpen, setForwardOpen] = useState(false); +const messagesById = useMemo( + () => new Map(messages.map((m) => [m.id, m])), + [messages], +); +const selectedMessages = useMemo( + () => + Array.from(selection.selectedIds) + .map((id) => messagesById.get(id)) + .filter((m): m is Message => !!m), + [selection.selectedIds, messagesById], +); + +useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === "Escape" && selection.active) selection.exit(); + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); +}, [selection]); + +useEffect(() => { + // Exit selection mode when the route channel changes + return () => { + if (selection.active && selection.channelId !== channelId) selection.exit(); + }; +}, [channelId, selection]); + +return ( + <> + {/* existing message list JSX */} + setForwardOpen(true)} /> + {forwardOpen && selectedMessages.length > 0 && ( + selection.exit()} + /> + )} + +); +``` + +- [ ] **Step 5: Tests** + +`SelectionActionBar.test.tsx`: + +- Renders nothing when not active. +- Renders count + buttons when active. +- Cancel button calls `exit()`. +- Forward button disabled when nothing selected. + +`MessageList.test.tsx` (extend): + +- When selection store is active for the channel, checkboxes appear on rows. +- Esc keypress calls `exit()`. +- Switching channels calls `exit()`. +- Toggling >100 messages emits the `forward.tooManySelected` toast. + +A new test for the eligibility helper (`__tests__/eligibility.test.ts`): + +- `isForwardable` true for text/long_text/file/image/forward; false for system/tracking/streaming/deleted. +- `computeForwardableRange` returns the inclusive slice and filters out ineligible ids. + +```bash +pnpm --filter @team9/client test -- SelectionActionBar MessageList eligibility --coverage +``` + +- [ ] **Step 6: Commit** + +```bash +git add apps/client/src/components/channel/forward/SelectionActionBar.tsx \ + apps/client/src/components/channel/forward/eligibility.ts \ + apps/client/src/components/channel/forward/__tests__/SelectionActionBar.test.tsx \ + apps/client/src/components/channel/forward/__tests__/eligibility.test.ts \ + apps/client/src/components/channel/MessageItem.tsx \ + apps/client/src/components/channel/MessageList.tsx \ + apps/client/src/components/channel/__tests__/MessageList.test.tsx +git commit -m "feat(client): selection mode + action bar in MessageList" +``` + +--- + +## Task 11: Hover toolbar + context menu wiring + +**Goal:** Add Forward + Select entry points on the per-message hover toolbar and right-click menu. Forward opens `ForwardDialog` preloaded with the single message; Select calls `selection.enter(channelId)` and immediately toggles the clicked message. + +**Files:** + +- Modify: `apps/client/src/components/channel/MessageHoverToolbar.tsx` +- Modify: `apps/client/src/components/channel/MessageContextMenu.tsx` +- Modify: `apps/client/src/components/channel/__tests__/MessageHoverToolbar.test.tsx` (create if absent) +- Modify: `apps/client/src/components/channel/__tests__/MessageContextMenu.test.tsx` (create if absent) + +**Acceptance Criteria:** + +- [ ] Hover toolbar shows new icons in this order: Reply (existing) → Forward (new, paper-plane) → Select (new, checkmark) → existing actions (Copy, etc.). +- [ ] Context menu adds `Forward` and `Select` items in the corresponding positions. +- [ ] Forward callback opens the dialog through a new `onForward` prop on both components, plumbed from `MessageItem` upward. +- [ ] Select callback calls `selection.enter(channelId)` then `selection.toggle(messageId)`. +- [ ] Both new items are hidden / disabled for ineligible messages (use `isForwardable`). +- [ ] New tests cover the click handlers + visibility rules. + +**Verify:** + +```bash +pnpm --filter @team9/client test -- MessageHoverToolbar MessageContextMenu +``` + +**Steps:** + +- [ ] **Step 1: Add icons + props to `MessageHoverToolbar.tsx`** + +```tsx +import { Forward, CheckSquare } from "lucide-react"; + +interface Props { + // existing + onForward?: () => void; + onSelect?: () => void; + forwardable?: boolean; +} + +// inside the toolbar JSX, after Reply button: +{ + forwardable && onForward && ( + + + + ); +} +{ + forwardable && onSelect && ( + + + + ); +} +``` + +- [ ] **Step 2: Add items to `MessageContextMenu.tsx`** + +After the existing Reply/Copy/Pin section, add (gated on `forwardable`): + +```tsx +{ + onForward && ( + + + {t("forward.contextMenu.forward")} + F + + ); +} +{ + onSelect && ( + + + {t("forward.contextMenu.select")} + + ); +} +``` + +Update the `MessageContextMenuProps` interface to include `onForward?`, `onSelect?`, `forwardable?: boolean`. + +- [ ] **Step 3: Wire up from `MessageItem.tsx`** + +In `MessageItem`, after computing `isEligible` (Task 10), wire: + +```tsx +const [forwardOpen, setForwardOpen] = useState(false); +const handleForward = () => setForwardOpen(true); +const handleSelect = () => { + selection.enter(message.channelId); + selection.toggle(message.id); +}; + + + ... + + ... +; + +{ + forwardOpen && ( + + ); +} +``` + +- [ ] **Step 4: Tests** + +`MessageHoverToolbar.test.tsx`: + +```tsx +it("shows Forward and Select icons when forwardable + handlers provided", () => { + const onForward = vi.fn(); + const onSelect = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByLabelText("Forward")); + expect(onForward).toHaveBeenCalled(); + fireEvent.click(screen.getByLabelText("Select")); + expect(onSelect).toHaveBeenCalled(); +}); + +it("hides Forward icon when not forwardable", () => { + render( + , + ); + expect(screen.queryByLabelText("Forward")).toBeNull(); +}); +``` + +`MessageContextMenu.test.tsx`: parallel coverage — items appear/disappear, click handlers fire. + +```bash +pnpm --filter @team9/client test -- MessageHoverToolbar MessageContextMenu --coverage +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/client/src/components/channel/MessageHoverToolbar.tsx \ + apps/client/src/components/channel/MessageContextMenu.tsx \ + apps/client/src/components/channel/MessageItem.tsx \ + apps/client/src/components/channel/__tests__/ +git commit -m "feat(client): wire forward+select into hover toolbar and context menu" +``` + +--- + +## Task 12: `MessageContent` dispatch — render forward card when type='forward' + +**Goal:** When a message arrives over the wire with `type === 'forward'`, replace the normal content body with ``. Make sure the channel scroll keeps working (no sudden layout jumps; React Query cache holds the new payload). + +**Files:** + +- Modify: `apps/client/src/components/channel/MessageContent.tsx` +- Modify: `apps/client/src/components/channel/__tests__/MessageContent.test.tsx` (or create) + +**Acceptance Criteria:** + +- [ ] `MessageContent` short-circuits to `` when `message.type === 'forward'`. +- [ ] Existing rendering paths for text/file/image/long_text/system/tracking unchanged. +- [ ] Test verifies the dispatch. + +**Verify:** + +```bash +pnpm --filter @team9/client test -- MessageContent +``` + +**Steps:** + +- [ ] **Step 1: Edit `MessageContent.tsx`** + +Near the top of the render function: + +```tsx +if (message.type === "forward") { + return ; +} +``` + +Import: + +```tsx +import { ForwardedMessageCard } from "./forward/ForwardedMessageCard"; +``` + +- [ ] **Step 2: Test** + +```tsx +it("renders ForwardedMessageCard for type=forward messages", () => { + const m = { id: "m", type: "forward", forward: { kind: "single", count: 1, items: [{ position: 0, ... }] } } as any; + render(); + expect(screen.getByText(/Forwarded from/i)).toBeInTheDocument(); +}); +``` + +- [ ] **Step 3: Run + commit** + +```bash +pnpm --filter @team9/client test -- MessageContent +git add apps/client/src/components/channel/MessageContent.tsx \ + apps/client/src/components/channel/__tests__/MessageContent.test.tsx +git commit -m "feat(client): dispatch MessageContent to ForwardedMessageCard for type=forward" +``` + +--- + +## Task 13: Manual smoke + final integration verification + +**Goal:** Boot the dev stack, exercise both single and multi-select flows in the browser, verify WS broadcast lands instantly on a second logged-in window, verify bundle viewer + jump-to-original both work, capture any regressions in adjacent features (thread, reactions, properties). + +**Files:** None (verification-only). + +**Acceptance Criteria:** + +- [ ] `pnpm dev` starts without errors. +- [ ] Single-message forward from channel A to channel B: card renders correctly, `Jump to original` lands on the original. +- [ ] Multi-select forward (5 messages) from A to B: bundle card renders with first 3 previews + "View all"; modal shows all 5 in order with attachments. +- [ ] Re-forward the bundle from B to C: chain depth 1; attempting to "Jump to original" lands on the bundle in B (the previous hop), not the original A messages. +- [ ] Source soft-delete: open A, delete one of the source messages, refresh B → snapshot still rendered, jump link hidden, "Source no longer available" footer shown when applicable. +- [ ] Try forward to an archived channel via direct API call (e.g. via curl) → 403. +- [ ] Try forwarding `>100` ids via curl → 400. +- [ ] Hover toolbar Forward shortcut (`F` while hovering) opens dialog. +- [ ] Esc exits selection mode without losing the channel scroll position. +- [ ] No regressions: thread reply still works, message edit still works on non-forward messages, reactions still work on forward messages. +- [ ] Run full suites: `pnpm --filter @team9/server test:cov && pnpm --filter @team9/client test:cov` → both 100% on new files; no drop on existing files. + +**Verify:** Manual + the two coverage commands above. + +**Steps:** + +- [ ] **Step 1: Boot dev stack** + +```bash +pnpm dev +``` + +Confirm gateway, im-worker, and Vite are all healthy. + +- [ ] **Step 2: Walk the golden paths in the browser** + +Use two browser windows (incognito for the second user). Run the scenarios in Acceptance Criteria one at a time, fixing any UI issues before moving on. Use the IDE to grep for unexpected console errors or React warnings. + +- [ ] **Step 3: Hit edge cases via curl** + +```bash +TOKEN=...; SRC=...; TGT=... +curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + "http://localhost:3000/api/v1/im/channels/$TGT/forward" \ + -d "{\"sourceChannelId\":\"$SRC\",\"sourceMessageIds\":$(node -e 'console.log(JSON.stringify(Array.from({length:101},()=>"00000000-0000-0000-0000-000000000000")))')}" +``` + +Expected: 400 with `forward.tooManySelected`. + +- [ ] **Step 4: Run coverage suites** + +```bash +pnpm --filter @team9/server test:cov 2>&1 | tail -40 +pnpm --filter @team9/client test:cov 2>&1 | tail -40 +``` + +Expected: 100% on new files, no regressions on existing. + +- [ ] **Step 5: Final commit (if any fix-ups)** + +If the manual pass surfaced anything, commit fixes with focused messages. Otherwise this task ends with no commit. + +- [ ] **Step 6: Open PR** + +Confirm with the user what target branch is wanted (per their CLAUDE.md: "PR 代码前要和用户确认目标分支。一般是 dev、个人分支,少数情况是 main"). Default suggestion: `dev`. + +```bash +gh pr create --base dev --title "feat(im): message forwarding (single + bundle)" --body "$(cat <<'EOF' +## Summary +- Adds single-message forwarding and multi-select bundle forwarding +- Carries source-location metadata (channel + workspace + sender + position) so agents can trace forwards back to origin + +See [docs/superpowers/specs/2026-05-02-message-forwarding-design.md](docs/superpowers/specs/2026-05-02-message-forwarding-design.md) for the full design and [docs/superpowers/plans/2026-05-02-message-forwarding.md](docs/superpowers/plans/2026-05-02-message-forwarding.md) for the implementation plan. + +## Test plan +- [ ] Backend unit + e2e suites pass at 100% coverage on new files +- [ ] Frontend unit suite passes at 100% coverage on new files +- [ ] Manual: single forward A→B, jump-to-original works +- [ ] Manual: bundle forward of 5 messages, viewer modal renders all +- [ ] Manual: re-forward chain depth limited to 1 hop +- [ ] Manual: snapshot still renders after source soft-delete + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- diff --git a/docs/superpowers/plans/2026-05-02-message-forwarding.md.tasks.json b/docs/superpowers/plans/2026-05-02-message-forwarding.md.tasks.json new file mode 100644 index 00000000..c627d17c --- /dev/null +++ b/docs/superpowers/plans/2026-05-02-message-forwarding.md.tasks.json @@ -0,0 +1,101 @@ +{ + "planPath": "docs/superpowers/plans/2026-05-02-message-forwarding.md", + "lastUpdated": "2026-05-02T07:14:19Z", + "tasks": [ + { + "id": 0, + "subject": "Task 0: i18n strings (en + zh-CN)", + "status": "pending", + "description": "**Goal:** Add forward.* keys to en + zh-CN locales.\n\n**Files:** apps/client/src/i18n/locales/{en,zh-CN}/channel.json\n\n**Verify:** node JSON.parse + pnpm --filter @team9/client lint\n\n```json:metadata\n{\"files\":[\"apps/client/src/i18n/locales/en/channel.json\",\"apps/client/src/i18n/locales/zh-CN/channel.json\"],\"verifyCommand\":\"pnpm --filter @team9/client lint\",\"acceptanceCriteria\":[\"all forward.* keys from spec §2.5 in both files\",\"JSON parses cleanly\"]}\n```" + }, + { + "id": 1, + "subject": "Task 1: DB schema — 'forward' enum + im_message_forwards table + migration", + "status": "pending", + "description": "**Goal:** Land the schema and migration for forward storage.\n\n**Files:** apps/server/libs/database/src/schemas/im/{messages.ts,message-forwards.ts,index.ts,message-forwards.spec.ts}; apps/server/libs/database/drizzle/_*.sql\n\n**Verify:** pnpm db:generate; pnpm db:migrate; pnpm --filter @team9/database test -- message-forwards\n\n```json:metadata\n{\"files\":[\"apps/server/libs/database/src/schemas/im/messages.ts\",\"apps/server/libs/database/src/schemas/im/message-forwards.ts\",\"apps/server/libs/database/src/schemas/im/index.ts\",\"apps/server/libs/database/src/schemas/im/message-forwards.spec.ts\"],\"verifyCommand\":\"pnpm --filter @team9/database test -- message-forwards\",\"acceptanceCriteria\":[\"messageTypeEnum includes 'forward'\",\"messageForwards table compiles with all columns + indexes\",\"db:generate produces single migration; db:migrate applies cleanly\",\"schema spec covers position ordering + cascade + set null + NOT NULL guard\"]}\n```" + }, + { + "id": 2, + "subject": "Task 2: ChannelsService.assertWriteAccess", + "status": "pending", + "description": "**Goal:** Extract write-access checks into a reusable assertWriteAccess; refactor MessagesController to use it.\n\n**Files:** apps/server/apps/gateway/src/im/channels/channels.service.ts (+ .spec.ts); apps/server/apps/gateway/src/im/messages/messages.controller.ts\n\n**Verify:** pnpm --filter @team9/server test -- channels.service messages.controller\n\n```json:metadata\n{\"files\":[\"apps/server/apps/gateway/src/im/channels/channels.service.ts\",\"apps/server/apps/gateway/src/im/channels/channels.service.spec.ts\",\"apps/server/apps/gateway/src/im/messages/messages.controller.ts\"],\"verifyCommand\":\"pnpm --filter @team9/server test -- channels.service messages.controller\",\"acceptanceCriteria\":[\"assertWriteAccess throws on non-member, archived, deactivated\",\"existing createChannelMessage flow regression-free\",\"unit tests cover happy + each rejection branch\"]}\n```" + }, + { + "id": 3, + "subject": "Task 3: ForwardsService — core business logic", + "status": "pending", + "blockedBy": [1, 2], + "description": "**Goal:** Build ForwardsService.forward() + getForwardItems() + hydrate() with snapshot capture, validation, error mapping, re-forward support, and forward-row insert with rollback on failure.\n\n**Files:** apps/server/apps/gateway/src/im/messages/forwards/{types.ts,forwards.service.ts,forwards.service.spec.ts}\n\n**Verify:** pnpm --filter @team9/server test -- forwards.service --coverage\n\n```json:metadata\n{\"files\":[\"apps/server/apps/gateway/src/im/messages/forwards/types.ts\",\"apps/server/apps/gateway/src/im/messages/forwards/forwards.service.ts\",\"apps/server/apps/gateway/src/im/messages/forwards/forwards.service.spec.ts\"],\"verifyCommand\":\"pnpm --filter @team9/server test -- forwards.service --coverage\",\"acceptanceCriteria\":[\"happy paths: single text/image/long_text/forward + bundle of 5\",\"rejection paths match spec §4.5 error matrix\",\"snapshot truncation at 100k\",\"forward message has zero rows in im_message_attachments\",\"failed insert triggers softDelete + 500\",\"100% coverage on forwards.service.ts and types.ts\"]}\n```" + }, + { + "id": 4, + "subject": "Task 4: ForwardsController — REST endpoints", + "status": "pending", + "blockedBy": [3], + "description": "**Goal:** POST /api/v1/im/channels/:id/forward + GET /api/v1/im/messages/:id/forward-items, registered in MessagesModule.\n\n**Files:** apps/server/apps/gateway/src/im/messages/forwards/{forwards.controller.ts,forwards.controller.spec.ts,dto/create-forward.dto.ts}; apps/server/apps/gateway/src/im/messages/messages.module.ts\n\n**Verify:** pnpm --filter @team9/server test -- forwards.controller\n\n```json:metadata\n{\"files\":[\"apps/server/apps/gateway/src/im/messages/forwards/forwards.controller.ts\",\"apps/server/apps/gateway/src/im/messages/forwards/forwards.controller.spec.ts\",\"apps/server/apps/gateway/src/im/messages/forwards/dto/create-forward.dto.ts\",\"apps/server/apps/gateway/src/im/messages/messages.module.ts\"],\"verifyCommand\":\"pnpm --filter @team9/server test -- forwards.controller\",\"acceptanceCriteria\":[\"POST validates DTO (UUID, length 1..100)\",\"GET returns full ForwardItemResponse[]\",\"both guarded by AuthGuard\",\"controller spec covers happy + delegation\"]}\n```" + }, + { + "id": 5, + "subject": "Task 5: MessagesService — hydrate forward field on reads + reject PATCH", + "status": "pending", + "blockedBy": [3], + "description": "**Goal:** Attach forward payload via ForwardsService.hydratePayload on getMessageWithDetails + bulk paths; threading userId through; PATCH rejected on type=forward.\n\n**Files:** apps/server/apps/gateway/src/im/messages/messages.service.ts (+ .spec.ts), messages.controller.ts (+ .spec.ts)\n\n**Verify:** pnpm --filter @team9/server test -- messages.service messages.controller\n\n```json:metadata\n{\"files\":[\"apps/server/apps/gateway/src/im/messages/messages.service.ts\",\"apps/server/apps/gateway/src/im/messages/messages.service.spec.ts\",\"apps/server/apps/gateway/src/im/messages/messages.controller.ts\",\"apps/server/apps/gateway/src/im/messages/messages.controller.spec.ts\"],\"verifyCommand\":\"pnpm --filter @team9/server test -- messages.service messages.controller\",\"acceptanceCriteria\":[\"MessageResponse adds optional forward field\",\"hydration runs only for type=forward\",\"bulk fetch hydrates each forward in page\",\"PATCH on forward returns 400 forward.editDisabled\"]}\n```" + }, + { + "id": 6, + "subject": "Task 6: Backend e2e — forward.e2e-spec.ts", + "status": "pending", + "blockedBy": [4, 5], + "description": "**Goal:** End-to-end coverage per spec §9.2 (single + bundle + WS broadcast + re-forward + soft-delete + access errors).\n\n**Files:** apps/server/apps/gateway/test/forward.e2e-spec.ts\n\n**Verify:** pnpm --filter @team9/server test:e2e -- forward\n\n```json:metadata\n{\"files\":[\"apps/server/apps/gateway/test/forward.e2e-spec.ts\"],\"verifyCommand\":\"pnpm --filter @team9/server test:e2e -- forward\",\"acceptanceCriteria\":[\"happy bundle path with WS broadcast\",\"single image: 0 rows in im_message_attachments for forward msg\",\"403 on no read or no write access\",\"re-forward chain depth 1\",\"soft-deleted source still renders snapshot\",\">100 + mixed-channel rejections\"]}\n```" + }, + { + "id": 7, + "subject": "Task 7: Frontend — types, API client, selection store", + "status": "pending", + "blockedBy": [4], + "description": "**Goal:** MessageType union + ForwardPayload/ForwardItem types; forwardApi.create/getItems; useForwardSelectionStore with cap enforcement.\n\n**Files:** apps/client/src/types/im.ts; apps/client/src/services/api.ts; apps/client/src/stores/useForwardSelectionStore.ts (+ __tests__)\n\n**Verify:** pnpm --filter @team9/client test -- useForwardSelectionStore\n\n```json:metadata\n{\"files\":[\"apps/client/src/types/im.ts\",\"apps/client/src/services/api.ts\",\"apps/client/src/stores/useForwardSelectionStore.ts\",\"apps/client/src/stores/__tests__/useForwardSelectionStore.test.ts\"],\"verifyCommand\":\"pnpm --filter @team9/client test -- useForwardSelectionStore\",\"acceptanceCriteria\":[\"MessageType includes 'forward'; Message has optional forward\",\"forwardApi.create + getItems hit right URLs\",\"selection store: enter/exit/toggle/addRange/clear, cap=100, channel-switch clears\",\"100% coverage on store\"]}\n```" + }, + { + "id": 8, + "subject": "Task 8: ForwardDialog + channel picker + preview", + "status": "pending", + "blockedBy": [7], + "description": "**Goal:** Modal that lets user pick target channel, shows single-quote or bundle preview, calls forwardApi.create.\n\n**Files:** apps/client/src/components/channel/forward/{ForwardDialog,ForwardChannelList,ForwardPreview}.tsx (+ __tests__/*.test.tsx); + forward.success i18n key\n\n**Verify:** pnpm --filter @team9/client test -- forward/__tests__\n\n```json:metadata\n{\"files\":[\"apps/client/src/components/channel/forward/ForwardDialog.tsx\",\"apps/client/src/components/channel/forward/ForwardChannelList.tsx\",\"apps/client/src/components/channel/forward/ForwardPreview.tsx\",\"apps/client/src/components/channel/forward/__tests__/ForwardDialog.test.tsx\",\"apps/client/src/components/channel/forward/__tests__/ForwardChannelList.test.tsx\",\"apps/client/src/components/channel/forward/__tests__/ForwardPreview.test.tsx\"],\"verifyCommand\":\"pnpm --filter @team9/client test -- forward/__tests__\",\"acceptanceCriteria\":[\"single vs bundle preview branch\",\"channel list excludes archived/deactivated/source\",\"confirm disabled until selection; spinner during request\",\"toast on success/error with mapped i18n\",\"100% coverage on three new files\"]}\n```" + }, + { + "id": 9, + "subject": "Task 9: ForwardedMessageCard + ForwardBundleViewer", + "status": "pending", + "blockedBy": [7], + "description": "**Goal:** Render forward messages on receiving end — quote card single, stacked bundle card with click-to-expand modal that lazy-fetches items.\n\n**Files:** apps/client/src/components/channel/forward/{ForwardedMessageCard,ForwardBundleViewer}.tsx (+ __tests__)\n\n**Verify:** pnpm --filter @team9/client test -- ForwardedMessageCard ForwardBundleViewer\n\n```json:metadata\n{\"files\":[\"apps/client/src/components/channel/forward/ForwardedMessageCard.tsx\",\"apps/client/src/components/channel/forward/ForwardBundleViewer.tsx\",\"apps/client/src/components/channel/forward/__tests__/ForwardedMessageCard.test.tsx\",\"apps/client/src/components/channel/forward/__tests__/ForwardBundleViewer.test.tsx\"],\"verifyCommand\":\"pnpm --filter @team9/client test -- ForwardedMessageCard ForwardBundleViewer\",\"acceptanceCriteria\":[\"single vs bundle branch\",\"jump-to-original respects canJumpToOriginal\",\"source-unavailable header when channelName null\",\"viewer modal lazy-fetches via forwardApi.getItems\",\"100% coverage on both files\"]}\n```" + }, + { + "id": 10, + "subject": "Task 10: SelectionActionBar + MessageList integration + MessageItem checkbox + eligibility helper", + "status": "pending", + "blockedBy": [7, 8], + "description": "**Goal:** Wire selection-mode UI: per-row checkboxes (with eligibility tooltip), bottom action bar, route-change exit, Esc cancel, Shift+click range, cap toast.\n\n**Files:** apps/client/src/components/channel/forward/{SelectionActionBar,eligibility}.{tsx,ts} (+ __tests__); MessageItem.tsx; MessageList.tsx\n\n**Verify:** pnpm --filter @team9/client test -- SelectionActionBar MessageList eligibility\n\n```json:metadata\n{\"files\":[\"apps/client/src/components/channel/forward/SelectionActionBar.tsx\",\"apps/client/src/components/channel/forward/eligibility.ts\",\"apps/client/src/components/channel/forward/__tests__/SelectionActionBar.test.tsx\",\"apps/client/src/components/channel/forward/__tests__/eligibility.test.ts\",\"apps/client/src/components/channel/MessageItem.tsx\",\"apps/client/src/components/channel/MessageList.tsx\"],\"verifyCommand\":\"pnpm --filter @team9/client test -- SelectionActionBar MessageList eligibility\",\"acceptanceCriteria\":[\"checkboxes appear on rows in selection mode\",\"Shift+click range adds inclusive eligible slice\",\"Esc + route-change exits mode\",\"cap=100 enforced with toast\",\"action bar opens ForwardDialog with selectedMessages\"]}\n```" + }, + { + "id": 11, + "subject": "Task 11: Hover toolbar + context menu wiring", + "status": "pending", + "blockedBy": [8, 10], + "description": "**Goal:** Add Forward + Select entry points on MessageHoverToolbar and MessageContextMenu; wire from MessageItem.\n\n**Files:** MessageHoverToolbar.tsx; MessageContextMenu.tsx; MessageItem.tsx; corresponding __tests__\n\n**Verify:** pnpm --filter @team9/client test -- MessageHoverToolbar MessageContextMenu\n\n```json:metadata\n{\"files\":[\"apps/client/src/components/channel/MessageHoverToolbar.tsx\",\"apps/client/src/components/channel/MessageContextMenu.tsx\",\"apps/client/src/components/channel/MessageItem.tsx\"],\"verifyCommand\":\"pnpm --filter @team9/client test -- MessageHoverToolbar MessageContextMenu\",\"acceptanceCriteria\":[\"Forward + Select icons on hover toolbar (gated on isForwardable)\",\"Forward + Select items in context menu (with F shortcut)\",\"Forward opens ForwardDialog single; Select enters mode + toggles\",\"tests for visibility and click handlers\"]}\n```" + }, + { + "id": 12, + "subject": "Task 12: MessageContent dispatch — render forward card when type=forward", + "status": "pending", + "blockedBy": [9], + "description": "**Goal:** MessageContent short-circuits to when type=forward.\n\n**Files:** apps/client/src/components/channel/MessageContent.tsx (+ __tests__)\n\n**Verify:** pnpm --filter @team9/client test -- MessageContent\n\n```json:metadata\n{\"files\":[\"apps/client/src/components/channel/MessageContent.tsx\",\"apps/client/src/components/channel/__tests__/MessageContent.test.tsx\"],\"verifyCommand\":\"pnpm --filter @team9/client test -- MessageContent\",\"acceptanceCriteria\":[\"renders ForwardedMessageCard for forward type\",\"existing branches unchanged\"]}\n```" + }, + { + "id": 13, + "subject": "Task 13: Manual smoke + final integration verification + PR", + "status": "pending", + "blockedBy": [6, 11, 12], + "description": "**Goal:** Boot dev stack, exercise both flows in browser, verify WS broadcast, capture regressions, run full coverage, open PR.\n\n**Files:** none (verification + PR)\n\n**Verify:** pnpm --filter @team9/server test:cov && pnpm --filter @team9/client test:cov\n\n```json:metadata\n{\"files\":[],\"verifyCommand\":\"pnpm --filter @team9/server test:cov && pnpm --filter @team9/client test:cov\",\"acceptanceCriteria\":[\"single + bundle forward smoke pass\",\"jump-to-original + bundle viewer work\",\"re-forward chain depth 1\",\"403 on archived target via curl\",\"400 on >100 via curl\",\"no regressions in thread/edit/reactions\",\"100% coverage on new files\",\"PR opened against dev (per user CLAUDE.md)\"]}\n```" + } + ] +} From a042e40a2514f106edd6a7e83a7c22463bfb31e3 Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 2 May 2026 15:20:13 +0800 Subject: [PATCH 05/23] feat(im): add forward.* i18n strings for en + zh-CN --- apps/client/src/i18n/locales/en/channel.json | 42 +++++++++++++++++++ .../src/i18n/locales/zh-CN/channel.json | 42 +++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/apps/client/src/i18n/locales/en/channel.json b/apps/client/src/i18n/locales/en/channel.json index 22483c2b..23975016 100644 --- a/apps/client/src/i18n/locales/en/channel.json +++ b/apps/client/src/i18n/locales/en/channel.json @@ -258,5 +258,47 @@ "tasks": "Tasks", "messages": "Messages" } + }, + "forward": { + "toolbar": { + "forward": "Forward", + "select": "Select" + }, + "contextMenu": { + "forward": "Forward", + "select": "Select" + }, + "dialog": { + "titleSingle": "Forward message", + "titleBundle": "Forward {{count}} messages", + "searchPlaceholder": "Search channels…", + "confirm": "Forward", + "cancel": "Cancel" + }, + "selection": { + "bar": "{{count}} selected", + "cancel": "Cancel" + }, + "tooManySelected": "You can forward up to 100 messages at once.", + "card": { + "fromChannel": "Forwarded from #{{channelName}}" + }, + "bundle": { + "title": "Chat record · {{count}} messages", + "viewAll": "View all", + "modalTitle": "Chat record from #{{channelName}}" + }, + "source": { + "unavailable": "Source no longer available", + "jumpTo": "Jump to original" + }, + "error": { + "notAllowed": "This message can't be forwarded.", + "noWriteAccess": "You can't forward to this channel.", + "noSourceAccess": "You no longer have access to the original channel.", + "mixedChannels": "All selected messages must come from the same channel.", + "empty": "Pick at least one message to forward.", + "notFound": "Original message could not be found." + } } } diff --git a/apps/client/src/i18n/locales/zh-CN/channel.json b/apps/client/src/i18n/locales/zh-CN/channel.json index 8797e2f2..24d64c85 100644 --- a/apps/client/src/i18n/locales/zh-CN/channel.json +++ b/apps/client/src/i18n/locales/zh-CN/channel.json @@ -255,5 +255,47 @@ "tasks": "任务", "messages": "消息" } + }, + "forward": { + "toolbar": { + "forward": "转发", + "select": "选择" + }, + "contextMenu": { + "forward": "转发", + "select": "选择" + }, + "dialog": { + "titleSingle": "转发消息", + "titleBundle": "转发 {{count}} 条消息", + "searchPlaceholder": "搜索频道…", + "confirm": "发送", + "cancel": "取消" + }, + "selection": { + "bar": "已选 {{count}} 条", + "cancel": "取消" + }, + "tooManySelected": "一次最多转发 100 条消息", + "card": { + "fromChannel": "转自 #{{channelName}}" + }, + "bundle": { + "title": "聊天记录 · {{count}} 条", + "viewAll": "查看全部", + "modalTitle": "来自 #{{channelName}} 的聊天记录" + }, + "source": { + "unavailable": "原消息已不可访问", + "jumpTo": "跳转到原消息" + }, + "error": { + "notAllowed": "此消息不可转发", + "noWriteAccess": "你没有该频道的发送权限", + "noSourceAccess": "你已无法访问原频道", + "mixedChannels": "多选转发的消息必须来自同一频道", + "empty": "请至少选择一条消息进行转发", + "notFound": "找不到原消息" + } } } From 8e52120653ca34df8f935a039024cdf4e7a4c883 Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 2 May 2026 15:28:29 +0800 Subject: [PATCH 06/23] feat(db): add im_message_forwards table and 'forward' message type 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 --- .../database/migrations/0058_strong_rage.sql | 27 + .../migrations/meta/0058_snapshot.json | 8406 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + .../libs/database/src/schemas/im/index.ts | 1 + .../src/schemas/im/message-forwards.spec.ts | 151 + .../src/schemas/im/message-forwards.ts | 70 + .../libs/database/src/schemas/im/messages.ts | 1 + 7 files changed, 8663 insertions(+) create mode 100644 apps/server/libs/database/migrations/0058_strong_rage.sql create mode 100644 apps/server/libs/database/migrations/meta/0058_snapshot.json create mode 100644 apps/server/libs/database/src/schemas/im/message-forwards.spec.ts create mode 100644 apps/server/libs/database/src/schemas/im/message-forwards.ts diff --git a/apps/server/libs/database/migrations/0058_strong_rage.sql b/apps/server/libs/database/migrations/0058_strong_rage.sql new file mode 100644 index 00000000..15711174 --- /dev/null +++ b/apps/server/libs/database/migrations/0058_strong_rage.sql @@ -0,0 +1,27 @@ +ALTER TYPE "public"."message_type" ADD VALUE 'forward';--> statement-breakpoint +CREATE TABLE "im_message_forwards" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "forwarded_message_id" uuid NOT NULL, + "position" integer NOT NULL, + "source_message_id" uuid, + "source_channel_id" uuid NOT NULL, + "source_workspace_id" uuid, + "source_sender_id" uuid, + "source_created_at" timestamp NOT NULL, + "source_seq_id" bigint, + "content_snapshot" varchar(100000), + "content_ast_snapshot" jsonb, + "attachments_snapshot" jsonb, + "source_type" varchar(32) NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "im_message_forwards" ADD CONSTRAINT "im_message_forwards_forwarded_message_id_im_messages_id_fk" FOREIGN KEY ("forwarded_message_id") REFERENCES "public"."im_messages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "im_message_forwards" ADD CONSTRAINT "im_message_forwards_source_message_id_im_messages_id_fk" FOREIGN KEY ("source_message_id") REFERENCES "public"."im_messages"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "im_message_forwards" ADD CONSTRAINT "im_message_forwards_source_channel_id_im_channels_id_fk" FOREIGN KEY ("source_channel_id") REFERENCES "public"."im_channels"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "im_message_forwards" ADD CONSTRAINT "im_message_forwards_source_workspace_id_tenants_id_fk" FOREIGN KEY ("source_workspace_id") REFERENCES "public"."tenants"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "im_message_forwards" ADD CONSTRAINT "im_message_forwards_source_sender_id_im_users_id_fk" FOREIGN KEY ("source_sender_id") REFERENCES "public"."im_users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_mf_forwarded" ON "im_message_forwards" USING btree ("forwarded_message_id");--> statement-breakpoint +CREATE INDEX "idx_mf_source_msg" ON "im_message_forwards" USING btree ("source_message_id");--> statement-breakpoint +CREATE INDEX "idx_mf_source_channel" ON "im_message_forwards" USING btree ("source_channel_id");--> statement-breakpoint +CREATE INDEX "idx_mf_source_workspace" ON "im_message_forwards" USING btree ("source_workspace_id"); \ No newline at end of file diff --git a/apps/server/libs/database/migrations/meta/0058_snapshot.json b/apps/server/libs/database/migrations/meta/0058_snapshot.json new file mode 100644 index 00000000..8fb71053 --- /dev/null +++ b/apps/server/libs/database/migrations/meta/0058_snapshot.json @@ -0,0 +1,8406 @@ +{ + "id": "9b2217ba-ec11-4243-9a76-7f4b5b06794f", + "prevId": "a92e98bf-510d-4f72-9cb8-da2ede0fb12b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.config": { + "name": "config", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_secret": { + "name": "is_secret", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_suggestions": { + "name": "document_suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "from_version_id": { + "name": "from_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "suggested_by": { + "name": "suggested_by", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "document_suggestion_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "result_version_id": { + "name": "result_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_document_suggestions_document_id": { + "name": "idx_document_suggestions_document_id", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_document_suggestions_status": { + "name": "idx_document_suggestions_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_suggestions_document_id_documents_id_fk": { + "name": "document_suggestions_document_id_documents_id_fk", + "tableFrom": "document_suggestions", + "tableTo": "documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_suggestions_from_version_id_document_versions_id_fk": { + "name": "document_suggestions_from_version_id_document_versions_id_fk", + "tableFrom": "document_suggestions", + "tableTo": "document_versions", + "columnsFrom": ["from_version_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "document_suggestions_result_version_id_document_versions_id_fk": { + "name": "document_suggestions_result_version_id_document_versions_id_fk", + "tableFrom": "document_suggestions", + "tableTo": "document_versions", + "columnsFrom": ["result_version_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_versions": { + "name": "document_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version_index": { + "name": "version_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_document_versions_document_id": { + "name": "idx_document_versions_document_id", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_versions_document_id_documents_id_fk": { + "name": "document_versions_document_id_documents_id_fk", + "tableFrom": "document_versions", + "tableTo": "documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_document_versions_doc_version": { + "name": "uq_document_versions_doc_version", + "nullsNotDistinct": false, + "columns": ["document_id", "version_index"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_type": { + "name": "document_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "privileges": { + "name": "privileges", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "current_version_id": { + "name": "current_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_documents_tenant_id": { + "name": "idx_documents_tenant_id", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_documents_document_type": { + "name": "idx_documents_document_type", + "columns": [ + { + "expression": "document_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_tenant_id_tenants_id_fk": { + "name": "documents_tenant_id_tenants_id_fk", + "tableFrom": "documents", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_folder_mounts": { + "name": "workspace_folder_mounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "mount_key": { + "name": "mount_key", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "folder_type": { + "name": "folder_type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "folder9_folder_id": { + "name": "folder9_folder_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_folder_mounts_unique": { + "name": "workspace_folder_mounts_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mount_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_folder_mounts_workspace_id_tenants_id_fk": { + "name": "workspace_folder_mounts_workspace_id_tenants_id_fk", + "tableFrom": "workspace_folder_mounts", + "tableTo": "tenants", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_ahand_devices": { + "name": "im_ahand_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "hub_device_id": { + "name": "hub_device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "nickname": { + "name": "nickname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ahand_devices_owner_idx": { + "name": "ahand_devices_owner_idx", + "columns": [ + { + "expression": "owner_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ahand_devices_status_idx": { + "name": "ahand_devices_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "im_ahand_devices_hub_device_id_unique": { + "name": "im_ahand_devices_hub_device_id_unique", + "nullsNotDistinct": false, + "columns": ["hub_device_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_audit_logs": { + "name": "im_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "performed_by": { + "name": "performed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_audit_logs_channel_created": { + "name": "idx_audit_logs_channel_created", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_logs_entity": { + "name": "idx_audit_logs_entity", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_logs_performer": { + "name": "idx_audit_logs_performer", + "columns": [ + { + "expression": "performed_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_audit_logs_channel_id_im_channels_id_fk": { + "name": "im_audit_logs_channel_id_im_channels_id_fk", + "tableFrom": "im_audit_logs", + "tableTo": "im_channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "im_audit_logs_performed_by_im_users_id_fk": { + "name": "im_audit_logs_performed_by_im_users_id_fk", + "tableFrom": "im_audit_logs", + "tableTo": "im_users", + "columnsFrom": ["performed_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_bots": { + "name": "im_bots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "bot_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mentor_id": { + "name": "mentor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "installed_application_id": { + "name": "installed_application_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "webhook_url": { + "name": "webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_headers": { + "name": "webhook_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "extra": { + "name": "extra", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "managed_provider": { + "name": "managed_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_meta": { + "name": "managed_meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_bots_user_id": { + "name": "idx_bots_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_bots_type": { + "name": "idx_bots_type", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_bots_owner_id": { + "name": "idx_bots_owner_id", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_bots_mentor_id": { + "name": "idx_bots_mentor_id", + "columns": [ + { + "expression": "mentor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_bots_installed_application_id": { + "name": "idx_bots_installed_application_id", + "columns": [ + { + "expression": "installed_application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_bots_access_token": { + "name": "idx_bots_access_token", + "columns": [ + { + "expression": "access_token", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_pattern_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bots_owner_app_unique": { + "name": "bots_owner_app_unique", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "installed_application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"im_bots\".\"owner_id\" IS NOT NULL AND \"im_bots\".\"installed_application_id\" IS NOT NULL AND \"im_bots\".\"extra\"->>'personalStaff' IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_bots_user_id_im_users_id_fk": { + "name": "im_bots_user_id_im_users_id_fk", + "tableFrom": "im_bots", + "tableTo": "im_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_bots_owner_id_im_users_id_fk": { + "name": "im_bots_owner_id_im_users_id_fk", + "tableFrom": "im_bots", + "tableTo": "im_users", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "im_bots_mentor_id_im_users_id_fk": { + "name": "im_bots_mentor_id_im_users_id_fk", + "tableFrom": "im_bots", + "tableTo": "im_users", + "columnsFrom": ["mentor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "im_bots_installed_application_id_im_installed_applications_id_fk": { + "name": "im_bots_installed_application_id_im_installed_applications_id_fk", + "tableFrom": "im_bots", + "tableTo": "im_installed_applications", + "columnsFrom": ["installed_application_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "im_bots_user_id_unique": { + "name": "im_bots_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_channel_members": { + "name": "im_channel_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "is_muted": { + "name": "is_muted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notifications_enabled": { + "name": "notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_in_dm_sidebar": { + "name": "show_in_dm_sidebar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "left_at": { + "name": "left_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_channel_members_user_id": { + "name": "idx_channel_members_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_channel_members_channel_id_im_channels_id_fk": { + "name": "im_channel_members_channel_id_im_channels_id_fk", + "tableFrom": "im_channel_members", + "tableTo": "im_channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_channel_members_user_id_im_users_id_fk": { + "name": "im_channel_members_user_id_im_users_id_fk", + "tableFrom": "im_channel_members", + "tableTo": "im_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_channel_user": { + "name": "unique_channel_user", + "nullsNotDistinct": false, + "columns": ["channel_id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_channel_property_definitions": { + "name": "im_channel_property_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "value_type": { + "name": "value_type", + "type": "property_value_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "is_native": { + "name": "is_native", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "ai_auto_fill": { + "name": "ai_auto_fill", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "ai_auto_fill_prompt": { + "name": "ai_auto_fill_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_required": { + "name": "is_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_value": { + "name": "default_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "show_in_chat_policy": { + "name": "show_in_chat_policy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'auto'" + }, + "allow_new_options": { + "name": "allow_new_options", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_channel_property_def_order": { + "name": "idx_channel_property_def_order", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_channel_property_definitions_channel_id_im_channels_id_fk": { + "name": "im_channel_property_definitions_channel_id_im_channels_id_fk", + "tableFrom": "im_channel_property_definitions", + "tableTo": "im_channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_channel_property_definitions_created_by_im_users_id_fk": { + "name": "im_channel_property_definitions_created_by_im_users_id_fk", + "tableFrom": "im_channel_property_definitions", + "tableTo": "im_users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_channel_property_def_key": { + "name": "uq_channel_property_def_key", + "nullsNotDistinct": false, + "columns": ["channel_id", "key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_channel_sections": { + "name": "im_channel_sections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_channel_sections_tenant": { + "name": "idx_channel_sections_tenant", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_channel_sections_tenant_id_tenants_id_fk": { + "name": "im_channel_sections_tenant_id_tenants_id_fk", + "tableFrom": "im_channel_sections", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_channel_sections_created_by_im_users_id_fk": { + "name": "im_channel_sections_created_by_im_users_id_fk", + "tableFrom": "im_channel_sections", + "tableTo": "im_users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_channel_tabs": { + "name": "im_channel_tabs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "view_id": { + "name": "view_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_builtin": { + "name": "is_builtin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_channel_tabs_channel": { + "name": "idx_channel_tabs_channel", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_channel_tabs_channel_id_im_channels_id_fk": { + "name": "im_channel_tabs_channel_id_im_channels_id_fk", + "tableFrom": "im_channel_tabs", + "tableTo": "im_channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_channel_tabs_view_id_im_channel_views_id_fk": { + "name": "im_channel_tabs_view_id_im_channel_views_id_fk", + "tableFrom": "im_channel_tabs", + "tableTo": "im_channel_views", + "columnsFrom": ["view_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "im_channel_tabs_created_by_im_users_id_fk": { + "name": "im_channel_tabs_created_by_im_users_id_fk", + "tableFrom": "im_channel_tabs", + "tableTo": "im_users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_channel_views": { + "name": "im_channel_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_channel_views_channel": { + "name": "idx_channel_views_channel", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_channel_views_channel_id_im_channels_id_fk": { + "name": "im_channel_views_channel_id_im_channels_id_fk", + "tableFrom": "im_channel_views", + "tableTo": "im_channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_channel_views_created_by_im_users_id_fk": { + "name": "im_channel_views_created_by_im_users_id_fk", + "tableFrom": "im_channel_views", + "tableTo": "im_users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_channels": { + "name": "im_channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "channel_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "section_id": { + "name": "section_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_activated": { + "name": "is_activated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "property_settings": { + "name": "property_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_channels_tenant": { + "name": "idx_channels_tenant", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_channels_tenant_id_tenants_id_fk": { + "name": "im_channels_tenant_id_tenants_id_fk", + "tableFrom": "im_channels", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_channels_created_by_im_users_id_fk": { + "name": "im_channels_created_by_im_users_id_fk", + "tableFrom": "im_channels", + "tableTo": "im_users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "im_channels_section_id_im_channel_sections_id_fk": { + "name": "im_channels_section_id_im_channel_sections_id_fk", + "tableFrom": "im_channels", + "tableTo": "im_channel_sections", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_email_verification_tokens": { + "name": "im_email_verification_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_verification_tokens_user_id": { + "name": "idx_verification_tokens_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_verification_tokens_token": { + "name": "idx_verification_tokens_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_verification_tokens_expires_at": { + "name": "idx_verification_tokens_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_email_verification_tokens_user_id_im_users_id_fk": { + "name": "im_email_verification_tokens_user_id_im_users_id_fk", + "tableFrom": "im_email_verification_tokens", + "tableTo": "im_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "im_email_verification_tokens_token_unique": { + "name": "im_email_verification_tokens_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_files": { + "name": "im_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "bucket": { + "name": "bucket", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "file_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'workspace'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "uploader_id": { + "name": "uploader_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_files_key": { + "name": "idx_files_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_files_tenant": { + "name": "idx_files_tenant", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_files_channel": { + "name": "idx_files_channel", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_files_uploader": { + "name": "idx_files_uploader", + "columns": [ + { + "expression": "uploader_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_files_tenant_id_tenants_id_fk": { + "name": "im_files_tenant_id_tenants_id_fk", + "tableFrom": "im_files", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_files_channel_id_im_channels_id_fk": { + "name": "im_files_channel_id_im_channels_id_fk", + "tableFrom": "im_files", + "tableTo": "im_channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "im_files_uploader_id_im_users_id_fk": { + "name": "im_files_uploader_id_im_users_id_fk", + "tableFrom": "im_files", + "tableTo": "im_users", + "columnsFrom": ["uploader_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_hive_send_failures": { + "name": "im_hive_send_failures", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tracking_channel_id": { + "name": "tracking_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "error_kind": { + "name": "error_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hive_send_failures_msg_bot_unique": { + "name": "hive_send_failures_msg_bot_unique", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hive_send_failures_agent_idx": { + "name": "hive_send_failures_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hive_send_failures_tenant_idx": { + "name": "hive_send_failures_tenant_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hive_send_failures_last_seen_idx": { + "name": "hive_send_failures_last_seen_idx", + "columns": [ + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_hive_send_failures_message_id_im_messages_id_fk": { + "name": "im_hive_send_failures_message_id_im_messages_id_fk", + "tableFrom": "im_hive_send_failures", + "tableTo": "im_messages", + "columnsFrom": ["message_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_hive_send_failures_bot_id_im_bots_id_fk": { + "name": "im_hive_send_failures_bot_id_im_bots_id_fk", + "tableFrom": "im_hive_send_failures", + "tableTo": "im_bots", + "columnsFrom": ["bot_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_users": { + "name": "im_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "user_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'offline'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_type": { + "name": "user_type", + "type": "user_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'human'" + }, + "language": { + "name": "language", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "time_zone": { + "name": "time_zone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "im_users_email_unique": { + "name": "im_users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "im_users_username_unique": { + "name": "im_users_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_user_email_change_requests": { + "name": "im_user_email_change_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "current_email": { + "name": "current_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "new_email": { + "name": "new_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "user_email_change_request_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_user_email_change_requests_user_id": { + "name": "idx_user_email_change_requests_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_email_change_requests_status": { + "name": "idx_user_email_change_requests_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_email_change_requests_expires_at": { + "name": "idx_user_email_change_requests_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_user_email_change_requests_pending_user": { + "name": "uq_user_email_change_requests_pending_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"im_user_email_change_requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_user_email_change_requests_pending_new_email": { + "name": "uq_user_email_change_requests_pending_new_email", + "columns": [ + { + "expression": "new_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"im_user_email_change_requests\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_user_email_change_requests_user_id_im_users_id_fk": { + "name": "im_user_email_change_requests_user_id_im_users_id_fk", + "tableFrom": "im_user_email_change_requests", + "tableTo": "im_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "im_user_email_change_requests_token_hash_unique": { + "name": "im_user_email_change_requests_token_hash_unique", + "nullsNotDistinct": false, + "columns": ["token_hash"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_messages": { + "name": "im_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "root_id": { + "name": "root_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_ast": { + "name": "content_ast", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "message_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_pinned": { + "name": "is_pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_edited": { + "name": "is_edited", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seq_id": { + "name": "seq_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "client_msg_id": { + "name": "client_msg_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "gateway_id": { + "name": "gateway_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_messages_channel_id": { + "name": "idx_messages_channel_id", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_messages_sender_id": { + "name": "idx_messages_sender_id", + "columns": [ + { + "expression": "sender_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_messages_parent_id": { + "name": "idx_messages_parent_id", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_messages_root_id": { + "name": "idx_messages_root_id", + "columns": [ + { + "expression": "root_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_messages_created_at": { + "name": "idx_messages_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_messages_seq_id": { + "name": "idx_messages_seq_id", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_messages_client_msg_id": { + "name": "idx_messages_client_msg_id", + "columns": [ + { + "expression": "client_msg_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_messages_channel_id_im_channels_id_fk": { + "name": "im_messages_channel_id_im_channels_id_fk", + "tableFrom": "im_messages", + "tableTo": "im_channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_messages_sender_id_im_users_id_fk": { + "name": "im_messages_sender_id_im_users_id_fk", + "tableFrom": "im_messages", + "tableTo": "im_users", + "columnsFrom": ["sender_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_message_attachments": { + "name": "im_message_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "file_key": { + "name": "file_key", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_message_attachments_message_id": { + "name": "idx_message_attachments_message_id", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_message_attachments_message_id_im_messages_id_fk": { + "name": "im_message_attachments_message_id_im_messages_id_fk", + "tableFrom": "im_message_attachments", + "tableTo": "im_messages", + "columnsFrom": ["message_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_message_reactions": { + "name": "im_message_reactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "emoji": { + "name": "emoji", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "im_message_reactions_message_id_im_messages_id_fk": { + "name": "im_message_reactions_message_id_im_messages_id_fk", + "tableFrom": "im_message_reactions", + "tableTo": "im_messages", + "columnsFrom": ["message_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_message_reactions_user_id_im_users_id_fk": { + "name": "im_message_reactions_user_id_im_users_id_fk", + "tableFrom": "im_message_reactions", + "tableTo": "im_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_reaction": { + "name": "unique_reaction", + "nullsNotDistinct": false, + "columns": ["message_id", "user_id", "emoji"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_message_acks": { + "name": "im_message_acks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "ack_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'sent'" + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "gateway_id": { + "name": "gateway_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_retry_at": { + "name": "last_retry_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_acks_message_id": { + "name": "idx_message_acks_message_id", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_acks_user_id": { + "name": "idx_message_acks_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_acks_status": { + "name": "idx_message_acks_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_acks_retry": { + "name": "idx_message_acks_retry", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "retry_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_message_acks_message_id_im_messages_id_fk": { + "name": "im_message_acks_message_id_im_messages_id_fk", + "tableFrom": "im_message_acks", + "tableTo": "im_messages", + "columnsFrom": ["message_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_message_acks_user_id_im_users_id_fk": { + "name": "im_message_acks_user_id_im_users_id_fk", + "tableFrom": "im_message_acks", + "tableTo": "im_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_message_user_ack": { + "name": "unique_message_user_ack", + "nullsNotDistinct": false, + "columns": ["message_id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_message_outbox": { + "name": "im_message_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "outbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_outbox_status": { + "name": "idx_outbox_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_outbox_created": { + "name": "idx_outbox_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_outbox_message_id": { + "name": "idx_outbox_message_id", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_outbox_status_created": { + "name": "idx_outbox_status_created", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_message_outbox_message_id_im_messages_id_fk": { + "name": "im_message_outbox_message_id_im_messages_id_fk", + "tableFrom": "im_message_outbox", + "tableTo": "im_messages", + "columnsFrom": ["message_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_user_channel_read_status": { + "name": "im_user_channel_read_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_read_message_id": { + "name": "last_read_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "unread_count": { + "name": "unread_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_sync_seq_id": { + "name": "last_sync_seq_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "im_user_channel_read_status_user_id_im_users_id_fk": { + "name": "im_user_channel_read_status_user_id_im_users_id_fk", + "tableFrom": "im_user_channel_read_status", + "tableTo": "im_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_user_channel_read_status_channel_id_im_channels_id_fk": { + "name": "im_user_channel_read_status_channel_id_im_channels_id_fk", + "tableFrom": "im_user_channel_read_status", + "tableTo": "im_channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_user_channel_read_status_last_read_message_id_im_messages_id_fk": { + "name": "im_user_channel_read_status_last_read_message_id_im_messages_id_fk", + "tableFrom": "im_user_channel_read_status", + "tableTo": "im_messages", + "columnsFrom": ["last_read_message_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_user_channel_read": { + "name": "unique_user_channel_read", + "nullsNotDistinct": false, + "columns": ["user_id", "channel_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_notifications": { + "name": "im_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "notification_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "notification_priority", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reference_type": { + "name": "reference_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "action_url": { + "name": "action_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_notifications_user_unread": { + "name": "idx_notifications_user_unread", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_read", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_archived", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notifications_user_category": { + "name": "idx_notifications_user_category", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notifications_user_type": { + "name": "idx_notifications_user_type", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notifications_expires": { + "name": "idx_notifications_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notifications_reference": { + "name": "idx_notifications_reference", + "columns": [ + { + "expression": "reference_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notifications_message": { + "name": "idx_notifications_message", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notifications_channel": { + "name": "idx_notifications_channel", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_notifications_user_id_im_users_id_fk": { + "name": "im_notifications_user_id_im_users_id_fk", + "tableFrom": "im_notifications", + "tableTo": "im_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_notifications_actor_id_im_users_id_fk": { + "name": "im_notifications_actor_id_im_users_id_fk", + "tableFrom": "im_notifications", + "tableTo": "im_users", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "im_notifications_tenant_id_tenants_id_fk": { + "name": "im_notifications_tenant_id_tenants_id_fk", + "tableFrom": "im_notifications", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_notifications_channel_id_im_channels_id_fk": { + "name": "im_notifications_channel_id_im_channels_id_fk", + "tableFrom": "im_notifications", + "tableTo": "im_channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_notifications_message_id_im_messages_id_fk": { + "name": "im_notifications_message_id_im_messages_id_fk", + "tableFrom": "im_notifications", + "tableTo": "im_messages", + "columnsFrom": ["message_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_channel_notification_mutes": { + "name": "im_channel_notification_mutes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "muted_until": { + "name": "muted_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_channel_mutes_user": { + "name": "idx_channel_mutes_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_channel_mutes_channel": { + "name": "idx_channel_mutes_channel", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_channel_notification_mutes_user_id_im_users_id_fk": { + "name": "im_channel_notification_mutes_user_id_im_users_id_fk", + "tableFrom": "im_channel_notification_mutes", + "tableTo": "im_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_channel_notification_mutes_channel_id_im_channels_id_fk": { + "name": "im_channel_notification_mutes_channel_id_im_channels_id_fk", + "tableFrom": "im_channel_notification_mutes", + "tableTo": "im_channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_channel_notification_mute": { + "name": "unique_channel_notification_mute", + "nullsNotDistinct": false, + "columns": ["user_id", "channel_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_notification_preferences": { + "name": "im_notification_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mentions_enabled": { + "name": "mentions_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "replies_enabled": { + "name": "replies_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "dms_enabled": { + "name": "dms_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "system_enabled": { + "name": "system_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "workspace_enabled": { + "name": "workspace_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "desktop_enabled": { + "name": "desktop_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sound_enabled": { + "name": "sound_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "dnd_enabled": { + "name": "dnd_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "dnd_start": { + "name": "dnd_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "dnd_end": { + "name": "dnd_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "im_notification_preferences_user_id_im_users_id_fk": { + "name": "im_notification_preferences_user_id_im_users_id_fk", + "tableFrom": "im_notification_preferences", + "tableTo": "im_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_user_notification_preferences": { + "name": "unique_user_notification_preferences", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_channel_search": { + "name": "im_channel_search", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_type": { + "name": "channel_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "member_count": { + "name": "member_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "channel_created_at": { + "name": "channel_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_channel_search_vector": { + "name": "idx_channel_search_vector", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_channel_search_tenant": { + "name": "idx_channel_search_tenant", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_channel_search_type": { + "name": "idx_channel_search_type", + "columns": [ + { + "expression": "channel_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_channel_search_channel_id_im_channels_id_fk": { + "name": "im_channel_search_channel_id_im_channels_id_fk", + "tableFrom": "im_channel_search", + "tableTo": "im_channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "im_channel_search_channel_id_unique": { + "name": "im_channel_search_channel_id_unique", + "nullsNotDistinct": false, + "columns": ["channel_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_file_search": { + "name": "im_file_search", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "channel_name": { + "name": "channel_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "uploader_id": { + "name": "uploader_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "uploader_username": { + "name": "uploader_username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "file_created_at": { + "name": "file_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_file_search_vector": { + "name": "idx_file_search_vector", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_file_search_channel": { + "name": "idx_file_search_channel", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_file_search_tenant": { + "name": "idx_file_search_tenant", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_file_search_mime": { + "name": "idx_file_search_mime", + "columns": [ + { + "expression": "mime_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_file_search_file_id_im_files_id_fk": { + "name": "im_file_search_file_id_im_files_id_fk", + "tableFrom": "im_file_search", + "tableTo": "im_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "im_file_search_file_id_unique": { + "name": "im_file_search_file_id_unique", + "nullsNotDistinct": false, + "columns": ["file_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_message_search": { + "name": "im_message_search", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": true + }, + "content_snapshot": { + "name": "content_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_name": { + "name": "channel_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "sender_id": { + "name": "sender_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sender_username": { + "name": "sender_username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "sender_display_name": { + "name": "sender_display_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "message_type": { + "name": "message_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "has_attachment": { + "name": "has_attachment", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_pinned": { + "name": "is_pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_thread_reply": { + "name": "is_thread_reply", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_created_at": { + "name": "message_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_message_search_vector": { + "name": "idx_message_search_vector", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_message_search_channel": { + "name": "idx_message_search_channel", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_search_sender": { + "name": "idx_message_search_sender", + "columns": [ + { + "expression": "sender_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_search_tenant": { + "name": "idx_message_search_tenant", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_search_created": { + "name": "idx_message_search_created", + "columns": [ + { + "expression": "message_created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_search_tenant_created": { + "name": "idx_message_search_tenant_created", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_message_search_message_id_im_messages_id_fk": { + "name": "im_message_search_message_id_im_messages_id_fk", + "tableFrom": "im_message_search", + "tableTo": "im_messages", + "columnsFrom": ["message_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "im_message_search_message_id_unique": { + "name": "im_message_search_message_id_unique", + "nullsNotDistinct": false, + "columns": ["message_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_user_search": { + "name": "im_user_search", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "user_created_at": { + "name": "user_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_user_search_vector": { + "name": "idx_user_search_vector", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_user_search_status": { + "name": "idx_user_search_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_user_search_user_id_im_users_id_fk": { + "name": "im_user_search_user_id_im_users_id_fk", + "tableFrom": "im_user_search", + "tableTo": "im_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "im_user_search_user_id_unique": { + "name": "im_user_search_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_installed_applications": { + "name": "im_installed_applications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "application_id": { + "name": "application_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "installed_by": { + "name": "installed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "secrets": { + "name": "secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "installed_application_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_installed_applications_tenant_id": { + "name": "idx_installed_applications_tenant_id", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_installed_applications_application_id": { + "name": "idx_installed_applications_application_id", + "columns": [ + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_installed_applications_status": { + "name": "idx_installed_applications_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_installed_applications_installed_by_im_users_id_fk": { + "name": "im_installed_applications_installed_by_im_users_id_fk", + "tableFrom": "im_installed_applications", + "tableTo": "im_users", + "columnsFrom": ["installed_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_push_subscriptions": { + "name": "im_push_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_push_sub_user": { + "name": "idx_push_sub_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_push_subscriptions_user_id_im_users_id_fk": { + "name": "im_push_subscriptions_user_id_im_users_id_fk", + "tableFrom": "im_push_subscriptions", + "tableTo": "im_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_push_endpoint": { + "name": "unique_push_endpoint", + "nullsNotDistinct": false, + "columns": ["endpoint"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_user_push_tokens": { + "name": "im_user_push_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "push_platform", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_user_push_tokens_user_id": { + "name": "idx_user_push_tokens_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_user_push_tokens_user_id_im_users_id_fk": { + "name": "im_user_push_tokens_user_id_im_users_id_fk", + "tableFrom": "im_user_push_tokens", + "tableTo": "im_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_user_push_token": { + "name": "uq_user_push_token", + "nullsNotDistinct": false, + "columns": ["user_id", "token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_message_properties": { + "name": "im_message_properties", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "property_definition_id": { + "name": "property_definition_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "text_value": { + "name": "text_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number_value": { + "name": "number_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "boolean_value": { + "name": "boolean_value", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "date_value": { + "name": "date_value", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "json_value": { + "name": "json_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "file_key": { + "name": "file_key", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "file_metadata": { + "name": "file_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_message_props_message": { + "name": "idx_message_props_message", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_props_def_text": { + "name": "idx_message_props_def_text", + "columns": [ + { + "expression": "property_definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "text_value", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_props_def_number": { + "name": "idx_message_props_def_number", + "columns": [ + { + "expression": "property_definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "number_value", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_props_def_date": { + "name": "idx_message_props_def_date", + "columns": [ + { + "expression": "property_definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_value", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_props_def_boolean": { + "name": "idx_message_props_def_boolean", + "columns": [ + { + "expression": "property_definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "boolean_value", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_message_properties_message_id_im_messages_id_fk": { + "name": "im_message_properties_message_id_im_messages_id_fk", + "tableFrom": "im_message_properties", + "tableTo": "im_messages", + "columnsFrom": ["message_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_message_properties_property_definition_id_im_channel_property_definitions_id_fk": { + "name": "im_message_properties_property_definition_id_im_channel_property_definitions_id_fk", + "tableFrom": "im_message_properties", + "tableTo": "im_channel_property_definitions", + "columnsFrom": ["property_definition_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_message_properties_created_by_im_users_id_fk": { + "name": "im_message_properties_created_by_im_users_id_fk", + "tableFrom": "im_message_properties", + "tableTo": "im_users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "im_message_properties_updated_by_im_users_id_fk": { + "name": "im_message_properties_updated_by_im_users_id_fk", + "tableFrom": "im_message_properties", + "tableTo": "im_users", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_message_property": { + "name": "uq_message_property", + "nullsNotDistinct": false, + "columns": ["message_id", "property_definition_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_message_forwards": { + "name": "im_message_forwards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "forwarded_message_id": { + "name": "forwarded_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source_message_id": { + "name": "source_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_channel_id": { + "name": "source_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_sender_id": { + "name": "source_sender_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_created_at": { + "name": "source_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "source_seq_id": { + "name": "source_seq_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "content_snapshot": { + "name": "content_snapshot", + "type": "varchar(100000)", + "primaryKey": false, + "notNull": false + }, + "content_ast_snapshot": { + "name": "content_ast_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "attachments_snapshot": { + "name": "attachments_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_mf_forwarded": { + "name": "idx_mf_forwarded", + "columns": [ + { + "expression": "forwarded_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_mf_source_msg": { + "name": "idx_mf_source_msg", + "columns": [ + { + "expression": "source_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_mf_source_channel": { + "name": "idx_mf_source_channel", + "columns": [ + { + "expression": "source_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_mf_source_workspace": { + "name": "idx_mf_source_workspace", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_message_forwards_forwarded_message_id_im_messages_id_fk": { + "name": "im_message_forwards_forwarded_message_id_im_messages_id_fk", + "tableFrom": "im_message_forwards", + "tableTo": "im_messages", + "columnsFrom": ["forwarded_message_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_message_forwards_source_message_id_im_messages_id_fk": { + "name": "im_message_forwards_source_message_id_im_messages_id_fk", + "tableFrom": "im_message_forwards", + "tableTo": "im_messages", + "columnsFrom": ["source_message_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "im_message_forwards_source_channel_id_im_channels_id_fk": { + "name": "im_message_forwards_source_channel_id_im_channels_id_fk", + "tableFrom": "im_message_forwards", + "tableTo": "im_channels", + "columnsFrom": ["source_channel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "im_message_forwards_source_workspace_id_tenants_id_fk": { + "name": "im_message_forwards_source_workspace_id_tenants_id_fk", + "tableFrom": "im_message_forwards", + "tableTo": "tenants", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "im_message_forwards_source_sender_id_im_users_id_fk": { + "name": "im_message_forwards_source_sender_id_im_users_id_fk", + "tableFrom": "im_message_forwards", + "tableTo": "im_users", + "columnsFrom": ["source_sender_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_message_relations": { + "name": "im_message_relations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_message_id": { + "name": "source_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_message_id": { + "name": "target_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "property_definition_id": { + "name": "property_definition_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "relation_kind": { + "name": "relation_kind", + "type": "relation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_mr_source_kind": { + "name": "idx_mr_source_kind", + "columns": [ + { + "expression": "source_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "relation_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_mr_target_kind": { + "name": "idx_mr_target_kind", + "columns": [ + { + "expression": "target_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "relation_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_mr_channel_kind": { + "name": "idx_mr_channel_kind", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "relation_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_mr_propdef": { + "name": "idx_mr_propdef", + "columns": [ + { + "expression": "property_definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_message_relations_tenant_id_tenants_id_fk": { + "name": "im_message_relations_tenant_id_tenants_id_fk", + "tableFrom": "im_message_relations", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_message_relations_channel_id_im_channels_id_fk": { + "name": "im_message_relations_channel_id_im_channels_id_fk", + "tableFrom": "im_message_relations", + "tableTo": "im_channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_message_relations_source_message_id_im_messages_id_fk": { + "name": "im_message_relations_source_message_id_im_messages_id_fk", + "tableFrom": "im_message_relations", + "tableTo": "im_messages", + "columnsFrom": ["source_message_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_message_relations_target_message_id_im_messages_id_fk": { + "name": "im_message_relations_target_message_id_im_messages_id_fk", + "tableFrom": "im_message_relations", + "tableTo": "im_messages", + "columnsFrom": ["target_message_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_message_relations_property_definition_id_im_channel_property_definitions_id_fk": { + "name": "im_message_relations_property_definition_id_im_channel_property_definitions_id_fk", + "tableFrom": "im_message_relations", + "tableTo": "im_channel_property_definitions", + "columnsFrom": ["property_definition_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_message_relations_created_by_im_users_id_fk": { + "name": "im_message_relations_created_by_im_users_id_fk", + "tableFrom": "im_message_relations", + "tableTo": "im_users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_message_relation_edge": { + "name": "uq_message_relation_edge", + "nullsNotDistinct": false, + "columns": [ + "source_message_id", + "property_definition_id", + "target_message_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "chk_message_relation_no_self": { + "name": "chk_message_relation_no_self", + "value": "\"im_message_relations\".\"source_message_id\" <> \"im_message_relations\".\"target_message_id\"" + } + }, + "isRLSEnabled": false + }, + "public.tenants": { + "name": "tenants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "tenant_plan", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tenants_slug_unique": { + "name": "tenants_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + }, + "tenants_domain_unique": { + "name": "tenants_domain_unique", + "nullsNotDistinct": false, + "columns": ["domain"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant_members": { + "name": "tenant_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "tenant_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "left_at": { + "name": "left_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_tenant_members_user_id": { + "name": "idx_tenant_members_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tenant_members_tenant_id_tenants_id_fk": { + "name": "tenant_members_tenant_id_tenants_id_fk", + "tableFrom": "tenant_members", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tenant_members_user_id_im_users_id_fk": { + "name": "tenant_members_user_id_im_users_id_fk", + "tableFrom": "tenant_members", + "tableTo": "im_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tenant_members_invited_by_im_users_id_fk": { + "name": "tenant_members_invited_by_im_users_id_fk", + "tableFrom": "tenant_members", + "tableTo": "im_users", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_tenant_user": { + "name": "unique_tenant_user", + "nullsNotDistinct": false, + "columns": ["tenant_id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_usage": { + "name": "invitation_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invitation_usage_invitation_id_workspace_invitations_id_fk": { + "name": "invitation_usage_invitation_id_workspace_invitations_id_fk", + "tableFrom": "invitation_usage", + "tableTo": "workspace_invitations", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_usage_user_id_im_users_id_fk": { + "name": "invitation_usage_user_id_im_users_id_fk", + "tableFrom": "invitation_usage", + "tableTo": "im_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitations": { + "name": "workspace_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "tenant_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "used_count": { + "name": "used_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_invitations_tenant_id": { + "name": "idx_workspace_invitations_tenant_id", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invitations_tenant_id_tenants_id_fk": { + "name": "workspace_invitations_tenant_id_tenants_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitations_created_by_im_users_id_fk": { + "name": "workspace_invitations_created_by_im_users_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "im_users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_invitations_code_unique": { + "name": "workspace_invitations_code_unique", + "nullsNotDistinct": false, + "columns": ["code"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onboarding_roles": { + "name": "onboarding_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "emoji": { + "name": "emoji", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "category_key": { + "name": "category_key", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "featured": { + "name": "featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "onboarding_roles_category_idx": { + "name": "onboarding_roles_category_idx", + "columns": [ + { + "expression": "category_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "onboarding_roles_active_idx": { + "name": "onboarding_roles_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "onboarding_roles_slug_unique": { + "name": "onboarding_roles_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_onboarding": { + "name": "workspace_onboarding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'in_progress'" + }, + "current_step": { + "name": "current_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "step_data": { + "name": "step_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_onboarding_tenant_idx": { + "name": "workspace_onboarding_tenant_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_onboarding_user_idx": { + "name": "workspace_onboarding_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_onboarding_tenant_id_tenants_id_fk": { + "name": "workspace_onboarding_tenant_id_tenants_id_fk", + "tableFrom": "workspace_onboarding", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_onboarding_user_id_im_users_id_fk": { + "name": "workspace_onboarding_user_id_im_users_id_fk", + "tableFrom": "workspace_onboarding", + "tableTo": "im_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_onboarding_tenant_user_unique": { + "name": "workspace_onboarding_tenant_user_unique", + "nullsNotDistinct": false, + "columns": ["tenant_id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine__routines": { + "name": "routine__routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "routine__status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'upcoming'" + }, + "schedule_type": { + "name": "schedule_type", + "type": "routine__schedule_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'once'" + }, + "schedule_config": { + "name": "schedule_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "current_execution_id": { + "name": "current_execution_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "creation_channel_id": { + "name": "creation_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "creation_session_id": { + "name": "creation_session_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_routine__routines_tenant_id": { + "name": "idx_routine__routines_tenant_id", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_routine__routines_bot_id": { + "name": "idx_routine__routines_bot_id", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_routine__routines_creator_id": { + "name": "idx_routine__routines_creator_id", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_routine__routines_status": { + "name": "idx_routine__routines_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_routine__routines_next_run_at": { + "name": "idx_routine__routines_next_run_at", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_routine__routines_tenant_status": { + "name": "idx_routine__routines_tenant_status", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_routine__routines_creation_channel_id": { + "name": "idx_routine__routines_creation_channel_id", + "columns": [ + { + "expression": "creation_channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_routine__routines_source_ref": { + "name": "idx_routine__routines_source_ref", + "columns": [ + { + "expression": "source_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine__routines_tenant_id_tenants_id_fk": { + "name": "routine__routines_tenant_id_tenants_id_fk", + "tableFrom": "routine__routines", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine__routines_bot_id_im_bots_id_fk": { + "name": "routine__routines_bot_id_im_bots_id_fk", + "tableFrom": "routine__routines", + "tableTo": "im_bots", + "columnsFrom": ["bot_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine__routines_creator_id_im_users_id_fk": { + "name": "routine__routines_creator_id_im_users_id_fk", + "tableFrom": "routine__routines", + "tableTo": "im_users", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "routine__routines_document_id_documents_id_fk": { + "name": "routine__routines_document_id_documents_id_fk", + "tableFrom": "routine__routines", + "tableTo": "documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "routine__routines_creation_channel_id_im_channels_id_fk": { + "name": "routine__routines_creation_channel_id_im_channels_id_fk", + "tableFrom": "routine__routines", + "tableTo": "im_channels", + "columnsFrom": ["creation_channel_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine__executions": { + "name": "routine__executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "routine_version": { + "name": "routine_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "routine__status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'in_progress'" + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "taskcast_task_id": { + "name": "taskcast_task_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "token_usage": { + "name": "token_usage", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "trigger_id": { + "name": "trigger_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "trigger_context": { + "name": "trigger_context", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "document_version_id": { + "name": "document_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_execution_id": { + "name": "source_execution_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_routine__executions_routine_id": { + "name": "idx_routine__executions_routine_id", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_routine__executions_status": { + "name": "idx_routine__executions_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_routine__executions_routine_version": { + "name": "idx_routine__executions_routine_version", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "routine_version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine__executions_routine_id_routine__routines_id_fk": { + "name": "routine__executions_routine_id_routine__routines_id_fk", + "tableFrom": "routine__executions", + "tableTo": "routine__routines", + "columnsFrom": ["routine_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine__executions_channel_id_im_channels_id_fk": { + "name": "routine__executions_channel_id_im_channels_id_fk", + "tableFrom": "routine__executions", + "tableTo": "im_channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "routine__executions_trigger_id_routine__triggers_id_fk": { + "name": "routine__executions_trigger_id_routine__triggers_id_fk", + "tableFrom": "routine__executions", + "tableTo": "routine__triggers", + "columnsFrom": ["trigger_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "routine__executions_document_version_id_document_versions_id_fk": { + "name": "routine__executions_document_version_id_document_versions_id_fk", + "tableFrom": "routine__executions", + "tableTo": "document_versions", + "columnsFrom": ["document_version_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_routine__executions_taskcast": { + "name": "uq_routine__executions_taskcast", + "nullsNotDistinct": false, + "columns": ["taskcast_task_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine__steps": { + "name": "routine__steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "order_index": { + "name": "order_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "routine__step_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token_usage": { + "name": "token_usage", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_routine__steps_execution_id": { + "name": "idx_routine__steps_execution_id", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_routine__steps_routine_id": { + "name": "idx_routine__steps_routine_id", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine__steps_execution_id_routine__executions_id_fk": { + "name": "routine__steps_execution_id_routine__executions_id_fk", + "tableFrom": "routine__steps", + "tableTo": "routine__executions", + "columnsFrom": ["execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine__steps_routine_id_routine__routines_id_fk": { + "name": "routine__steps_routine_id_routine__routines_id_fk", + "tableFrom": "routine__steps", + "tableTo": "routine__routines", + "columnsFrom": ["routine_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine__deliverables": { + "name": "routine__deliverables", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_routine__deliverables_execution_id": { + "name": "idx_routine__deliverables_execution_id", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_routine__deliverables_routine_id": { + "name": "idx_routine__deliverables_routine_id", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine__deliverables_execution_id_routine__executions_id_fk": { + "name": "routine__deliverables_execution_id_routine__executions_id_fk", + "tableFrom": "routine__deliverables", + "tableTo": "routine__executions", + "columnsFrom": ["execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine__deliverables_routine_id_routine__routines_id_fk": { + "name": "routine__deliverables_routine_id_routine__routines_id_fk", + "tableFrom": "routine__deliverables", + "tableTo": "routine__routines", + "columnsFrom": ["routine_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine__interventions": { + "name": "routine__interventions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "step_id": { + "name": "step_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "routine__intervention_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_routine__interventions_execution_id": { + "name": "idx_routine__interventions_execution_id", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_routine__interventions_routine_id": { + "name": "idx_routine__interventions_routine_id", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_routine__interventions_status": { + "name": "idx_routine__interventions_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine__interventions_execution_id_routine__executions_id_fk": { + "name": "routine__interventions_execution_id_routine__executions_id_fk", + "tableFrom": "routine__interventions", + "tableTo": "routine__executions", + "columnsFrom": ["execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine__interventions_routine_id_routine__routines_id_fk": { + "name": "routine__interventions_routine_id_routine__routines_id_fk", + "tableFrom": "routine__interventions", + "tableTo": "routine__routines", + "columnsFrom": ["routine_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine__interventions_step_id_routine__steps_id_fk": { + "name": "routine__interventions_step_id_routine__steps_id_fk", + "tableFrom": "routine__interventions", + "tableTo": "routine__steps", + "columnsFrom": ["step_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "routine__interventions_resolved_by_im_users_id_fk": { + "name": "routine__interventions_resolved_by_im_users_id_fk", + "tableFrom": "routine__interventions", + "tableTo": "im_users", + "columnsFrom": ["resolved_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine__triggers": { + "name": "routine__triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "routine__trigger_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_routine__triggers_routine_id": { + "name": "idx_routine__triggers_routine_id", + "columns": [ + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_routine__triggers_scan": { + "name": "idx_routine__triggers_scan", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine__triggers_routine_id_routine__routines_id_fk": { + "name": "routine__triggers_routine_id_routine__routines_id_fk", + "tableFrom": "routine__triggers", + "tableTo": "routine__routines", + "columnsFrom": ["routine_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resources": { + "name": "resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "resource__type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "resource__status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'offline'" + }, + "authorizations": { + "name": "authorizations", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_resources_tenant_id": { + "name": "idx_resources_tenant_id", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_resources_tenant_type": { + "name": "idx_resources_tenant_type", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_resources_status": { + "name": "idx_resources_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resources_tenant_id_tenants_id_fk": { + "name": "resources_tenant_id_tenants_id_fk", + "tableFrom": "resources", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resources_creator_id_im_users_id_fk": { + "name": "resources_creator_id_im_users_id_fk", + "tableFrom": "resources", + "tableTo": "im_users", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resource_usage_logs": { + "name": "resource_usage_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "resource__actor_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_resource_usage_logs_resource_created": { + "name": "idx_resource_usage_logs_resource_created", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_resource_usage_logs_actor_created": { + "name": "idx_resource_usage_logs_actor_created", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_usage_logs_resource_id_resources_id_fk": { + "name": "resource_usage_logs_resource_id_resources_id_fk", + "tableFrom": "resource_usage_logs", + "tableTo": "resources", + "columnsFrom": ["resource_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_usage_logs_routine_id_routine__routines_id_fk": { + "name": "resource_usage_logs_routine_id_routine__routines_id_fk", + "tableFrom": "resource_usage_logs", + "tableTo": "routine__routines", + "columnsFrom": ["routine_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "resource_usage_logs_execution_id_routine__executions_id_fk": { + "name": "resource_usage_logs_execution_id_routine__executions_id_fk", + "tableFrom": "resource_usage_logs", + "tableTo": "routine__executions", + "columnsFrom": ["execution_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "skill__type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "current_version": { + "name": "current_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_skills_tenant_id": { + "name": "idx_skills_tenant_id", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_tenant_id_tenants_id_fk": { + "name": "skills_tenant_id_tenants_id_fk", + "tableFrom": "skills", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skills_creator_id_im_users_id_fk": { + "name": "skills_creator_id_im_users_id_fk", + "tableFrom": "skills", + "tableTo": "im_users", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_versions": { + "name": "skill_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "skill_version__status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "file_manifest": { + "name": "file_manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "suggested_by": { + "name": "suggested_by", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_skill_versions_skill_version": { + "name": "idx_skill_versions_skill_version", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_versions_skill_id_skills_id_fk": { + "name": "skill_versions_skill_id_skills_id_fk", + "tableFrom": "skill_versions", + "tableTo": "skills", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_versions_creator_id_im_users_id_fk": { + "name": "skill_versions_creator_id_im_users_id_fk", + "tableFrom": "skill_versions", + "tableTo": "im_users", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_files": { + "name": "skill_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_skill_files_skill_id": { + "name": "idx_skill_files_skill_id", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_files_skill_id_skills_id_fk": { + "name": "skill_files_skill_id_skills_id_fk", + "tableFrom": "skill_files", + "tableTo": "skills", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_wikis": { + "name": "workspace_wikis", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "folder9_folder_id": { + "name": "folder9_folder_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approval_mode": { + "name": "approval_mode", + "type": "wiki_approval_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'auto'" + }, + "human_permission": { + "name": "human_permission", + "type": "wiki_permission_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'write'" + }, + "agent_permission": { + "name": "agent_permission", + "type": "wiki_permission_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'read'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_wikis_workspace_slug_unique": { + "name": "workspace_wikis_workspace_slug_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_wikis\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_wikis_folder9_unique": { + "name": "workspace_wikis_folder9_unique", + "columns": [ + { + "expression": "folder9_folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_wikis_workspace_idx": { + "name": "workspace_wikis_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_wikis_workspace_id_tenants_id_fk": { + "name": "workspace_wikis_workspace_id_tenants_id_fk", + "tableFrom": "workspace_wikis", + "tableTo": "tenants", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.document_suggestion_status": { + "name": "document_suggestion_status", + "schema": "public", + "values": ["pending", "approved", "rejected"] + }, + "public.bot_type": { + "name": "bot_type", + "schema": "public", + "values": ["system", "custom", "webhook"] + }, + "public.member_role": { + "name": "member_role", + "schema": "public", + "values": ["owner", "admin", "member"] + }, + "public.property_value_type": { + "name": "property_value_type", + "schema": "public", + "values": [ + "text", + "number", + "boolean", + "single_select", + "multi_select", + "person", + "date", + "timestamp", + "date_range", + "timestamp_range", + "recurring", + "url", + "message_ref", + "file", + "image", + "tags" + ] + }, + "public.channel_type": { + "name": "channel_type", + "schema": "public", + "values": [ + "direct", + "public", + "private", + "task", + "tracking", + "echo", + "routine-session", + "topic-session" + ] + }, + "public.file_visibility": { + "name": "file_visibility", + "schema": "public", + "values": ["private", "channel", "workspace", "public"] + }, + "public.user_status": { + "name": "user_status", + "schema": "public", + "values": ["online", "offline", "away", "busy"] + }, + "public.user_type": { + "name": "user_type", + "schema": "public", + "values": ["human", "bot", "system"] + }, + "public.user_email_change_request_status": { + "name": "user_email_change_request_status", + "schema": "public", + "values": ["pending", "confirmed", "cancelled", "expired"] + }, + "public.message_type": { + "name": "message_type", + "schema": "public", + "values": [ + "text", + "file", + "image", + "system", + "tracking", + "long_text", + "forward" + ] + }, + "public.ack_status": { + "name": "ack_status", + "schema": "public", + "values": ["sent", "delivered", "read"] + }, + "public.outbox_status": { + "name": "outbox_status", + "schema": "public", + "values": ["pending", "processing", "completed", "failed"] + }, + "public.notification_category": { + "name": "notification_category", + "schema": "public", + "values": ["message", "system", "workspace"] + }, + "public.notification_priority": { + "name": "notification_priority", + "schema": "public", + "values": ["low", "normal", "high", "urgent"] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "mention", + "channel_mention", + "everyone_mention", + "here_mention", + "reply", + "thread_reply", + "dm_received", + "system_announcement", + "maintenance_notice", + "version_update", + "workspace_invitation", + "role_changed", + "member_joined", + "member_left", + "channel_invite" + ] + }, + "public.installed_application_status": { + "name": "installed_application_status", + "schema": "public", + "values": ["active", "inactive", "pending", "error"] + }, + "public.push_platform": { + "name": "push_platform", + "schema": "public", + "values": ["ios", "android"] + }, + "public.relation_kind": { + "name": "relation_kind", + "schema": "public", + "values": ["parent", "related"] + }, + "public.tenant_plan": { + "name": "tenant_plan", + "schema": "public", + "values": ["free", "pro", "enterprise"] + }, + "public.tenant_role": { + "name": "tenant_role", + "schema": "public", + "values": ["owner", "admin", "member", "guest"] + }, + "public.routine__schedule_type": { + "name": "routine__schedule_type", + "schema": "public", + "values": ["once", "recurring"] + }, + "public.routine__status": { + "name": "routine__status", + "schema": "public", + "values": [ + "draft", + "upcoming", + "in_progress", + "paused", + "pending_action", + "completed", + "failed", + "stopped", + "timeout" + ] + }, + "public.routine__step_status": { + "name": "routine__step_status", + "schema": "public", + "values": ["pending", "in_progress", "completed", "failed"] + }, + "public.routine__intervention_status": { + "name": "routine__intervention_status", + "schema": "public", + "values": ["pending", "resolved", "expired"] + }, + "public.routine__trigger_type": { + "name": "routine__trigger_type", + "schema": "public", + "values": ["manual", "interval", "schedule", "channel_message"] + }, + "public.resource__status": { + "name": "resource__status", + "schema": "public", + "values": ["online", "offline", "error", "configuring"] + }, + "public.resource__type": { + "name": "resource__type", + "schema": "public", + "values": ["agent_computer", "api"] + }, + "public.resource__actor_type": { + "name": "resource__actor_type", + "schema": "public", + "values": ["agent", "user"] + }, + "public.skill__type": { + "name": "skill__type", + "schema": "public", + "values": ["claude_code_skill", "prompt_template", "general"] + }, + "public.skill_version__status": { + "name": "skill_version__status", + "schema": "public", + "values": ["draft", "published", "suggested", "rejected"] + }, + "public.wiki_approval_mode": { + "name": "wiki_approval_mode", + "schema": "public", + "values": ["auto", "review"] + }, + "public.wiki_permission_level": { + "name": "wiki_permission_level", + "schema": "public", + "values": ["read", "propose", "write"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/server/libs/database/migrations/meta/_journal.json b/apps/server/libs/database/migrations/meta/_journal.json index 7e9963ba..f1baa3ee 100644 --- a/apps/server/libs/database/migrations/meta/_journal.json +++ b/apps/server/libs/database/migrations/meta/_journal.json @@ -407,6 +407,13 @@ "when": 1777575466136, "tag": "0057_yielding_radioactive_man", "breakpoints": true + }, + { + "idx": 58, + "version": "7", + "when": 1777706743341, + "tag": "0058_strong_rage", + "breakpoints": true } ] } diff --git a/apps/server/libs/database/src/schemas/im/index.ts b/apps/server/libs/database/src/schemas/im/index.ts index 61e44ad8..9d4c304e 100644 --- a/apps/server/libs/database/src/schemas/im/index.ts +++ b/apps/server/libs/database/src/schemas/im/index.ts @@ -23,6 +23,7 @@ export * from './message-properties.js'; export * from './audit-logs.js'; export * from './channel-views.js'; export * from './channel-tabs.js'; +export * from './message-forwards.js'; export * from './message-relations.js'; export * from './ahand-devices.js'; export * from './hive-send-failures.js'; diff --git a/apps/server/libs/database/src/schemas/im/message-forwards.spec.ts b/apps/server/libs/database/src/schemas/im/message-forwards.spec.ts new file mode 100644 index 00000000..d3128be8 --- /dev/null +++ b/apps/server/libs/database/src/schemas/im/message-forwards.spec.ts @@ -0,0 +1,151 @@ +/** + * Structural smoke tests for the im_message_forwards Drizzle schema. + * + * Mirrors the pattern established by ahand-devices.schema.spec.ts — no real + * database connection, just type + column surface guards and FK-config + * inspection. The four acceptance-criteria scenarios map to: + * + * 1. Insert a single forward row (all required fields) — verified via + * NewMessageForward type assignment and column presence. + * 2. Cascade on forwardedMessageId delete — verified via FK config. + * 3. Set null on sourceMessageId delete — verified via FK config. + * 4. NOT NULL guard on sourceChannelId — verified via column.notNull. + */ +import { describe, it, expect } from '@jest/globals'; +import * as schema from '../index.js'; + +const FK_INLINE = Symbol.for('drizzle:PgInlineForeignKeys'); + +type ForeignKeyEntry = { + onDelete: string; + reference: () => { + columns: { name: string }[]; + foreignTable: { [key: symbol]: string }; + }; +}; + +describe('im_message_forwards schema', () => { + it('exports messageForwards table with all required columns', () => { + const table = schema.messageForwards; + expect(table).toBeDefined(); + + const expectedCols = [ + 'id', + 'forwardedMessageId', + 'position', + 'sourceMessageId', + 'sourceChannelId', + 'sourceWorkspaceId', + 'sourceSenderId', + 'sourceCreatedAt', + 'sourceSeqId', + 'contentSnapshot', + 'contentAstSnapshot', + 'attachmentsSnapshot', + 'sourceType', + 'createdAt', + ] as const; + + for (const col of expectedCols) { + expect(table[col as keyof typeof table]).toBeDefined(); + } + }); + + it('insert (1): NewMessageForward accepts all required fields and persists type contract', () => { + // This verifies the TypeScript type allows a minimal valid insert shape. + // DB-level persistence is verified by pnpm db:migrate + psql in the task notes. + const row: schema.NewMessageForward = { + forwardedMessageId: '00000000-0000-0000-0000-000000000001', + position: 0, + sourceChannelId: '00000000-0000-0000-0000-000000000002', + sourceCreatedAt: new Date('2026-01-01T00:00:00Z'), + sourceType: 'text', + }; + + expect(row.forwardedMessageId).toBe('00000000-0000-0000-0000-000000000001'); + expect(row.position).toBe(0); + expect(row.sourceChannelId).toBe('00000000-0000-0000-0000-000000000002'); + expect(row.sourceType).toBe('text'); + // Optional fields default to undefined (DB supplies null or default) + expect(row.sourceMessageId).toBeUndefined(); + expect(row.sourceWorkspaceId).toBeUndefined(); + expect(row.sourceSenderId).toBeUndefined(); + expect(row.contentSnapshot).toBeUndefined(); + expect(row.id).toBeUndefined(); // DB generates via gen_random_uuid() + }); + + it('cascade (2): forwardedMessageId FK has onDelete=cascade', () => { + const table = schema.messageForwards; + const fks: ForeignKeyEntry[] = ( + table as unknown as Record + )[FK_INLINE]; + + expect(fks).toBeDefined(); + + const cascadeFk = fks.find((fk) => { + const ref = fk.reference(); + return ref.columns.some((c) => c.name === 'forwarded_message_id'); + }); + + expect(cascadeFk).toBeDefined(); + expect(cascadeFk?.onDelete).toBe('cascade'); + }); + + it('set null (3): sourceMessageId FK has onDelete=set null', () => { + const table = schema.messageForwards; + const fks: ForeignKeyEntry[] = ( + table as unknown as Record + )[FK_INLINE]; + + const setNullFk = fks.find((fk) => { + const ref = fk.reference(); + return ref.columns.some((c) => c.name === 'source_message_id'); + }); + + expect(setNullFk).toBeDefined(); + expect(setNullFk?.onDelete).toBe('set null'); + // sourceChannelId is a separate column and remains not-null (denormalized) + expect(schema.messageForwards.sourceChannelId.notNull).toBe(true); + }); + + it('not null guard (4): sourceChannelId is NOT NULL in schema', () => { + expect(schema.messageForwards.sourceChannelId.notNull).toBe(true); + }); + + it('nullable fields encode correct types', () => { + // sourceMessageId, sourceWorkspaceId, sourceSenderId, sourceSeqId are nullable + const row: Partial = { + sourceMessageId: null, + sourceWorkspaceId: null, + sourceSenderId: null, + sourceSeqId: null, + contentSnapshot: null, + contentAstSnapshot: null, + attachmentsSnapshot: null, + }; + + expect(row.sourceMessageId).toBeNull(); + expect(row.sourceWorkspaceId).toBeNull(); + expect(row.sourceSenderId).toBeNull(); + expect(row.sourceSeqId).toBeNull(); + expect(row.contentSnapshot).toBeNull(); + expect(row.contentAstSnapshot).toBeNull(); + expect(row.attachmentsSnapshot).toBeNull(); + }); + + it('messageTypeEnum includes forward as the last value', () => { + const values = schema.messageTypeEnum.enumValues; + expect(values).toContain('forward'); + expect(values[values.length - 1]).toBe('forward'); + // Preserve existing ordinals + expect(values).toEqual([ + 'text', + 'file', + 'image', + 'system', + 'tracking', + 'long_text', + 'forward', + ]); + }); +}); diff --git a/apps/server/libs/database/src/schemas/im/message-forwards.ts b/apps/server/libs/database/src/schemas/im/message-forwards.ts new file mode 100644 index 00000000..e861942f --- /dev/null +++ b/apps/server/libs/database/src/schemas/im/message-forwards.ts @@ -0,0 +1,70 @@ +import { + pgTable, + uuid, + integer, + timestamp, + jsonb, + varchar, + bigint, + index, +} from 'drizzle-orm/pg-core'; +import { messages } from './messages.js'; +import { channels } from './channels.js'; +import { tenants } from '../tenant/tenants.js'; +import { users } from './users.js'; + +export interface ForwardAttachmentSnapshot { + originalAttachmentId: string; + fileName: string; + fileUrl: string; + fileKey: string | null; + fileSize: number; + mimeType: string; + thumbnailUrl: string | null; + width: number | null; + height: number | null; +} + +export const messageForwards = pgTable( + 'im_message_forwards', + { + id: uuid('id').primaryKey().defaultRandom(), + forwardedMessageId: uuid('forwarded_message_id') + .references(() => messages.id, { onDelete: 'cascade' }) + .notNull(), + position: integer('position').notNull(), + sourceMessageId: uuid('source_message_id').references(() => messages.id, { + onDelete: 'set null', + }), + sourceChannelId: uuid('source_channel_id') + .references(() => channels.id) + .notNull(), + sourceWorkspaceId: uuid('source_workspace_id').references( + () => tenants.id, + { onDelete: 'set null' }, + ), + sourceSenderId: uuid('source_sender_id').references(() => users.id, { + onDelete: 'set null', + }), + sourceCreatedAt: timestamp('source_created_at').notNull(), + sourceSeqId: bigint('source_seq_id', { mode: 'bigint' }), + contentSnapshot: varchar('content_snapshot', { length: 100_000 }), + contentAstSnapshot: jsonb('content_ast_snapshot').$type< + Record + >(), + attachmentsSnapshot: jsonb('attachments_snapshot').$type< + ForwardAttachmentSnapshot[] + >(), + sourceType: varchar('source_type', { length: 32 }).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (table) => [ + index('idx_mf_forwarded').on(table.forwardedMessageId), + index('idx_mf_source_msg').on(table.sourceMessageId), + index('idx_mf_source_channel').on(table.sourceChannelId), + index('idx_mf_source_workspace').on(table.sourceWorkspaceId), + ], +); + +export type MessageForward = typeof messageForwards.$inferSelect; +export type NewMessageForward = typeof messageForwards.$inferInsert; diff --git a/apps/server/libs/database/src/schemas/im/messages.ts b/apps/server/libs/database/src/schemas/im/messages.ts index 4925c11f..2d85fba2 100644 --- a/apps/server/libs/database/src/schemas/im/messages.ts +++ b/apps/server/libs/database/src/schemas/im/messages.ts @@ -20,6 +20,7 @@ export const messageTypeEnum = pgEnum('message_type', [ 'system', 'tracking', 'long_text', + 'forward', ]); export const messages = pgTable( From 5063664576479b2929d0cf9c4f23e0f798f1ec78 Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 2 May 2026 15:36:38 +0800 Subject: [PATCH 07/23] refactor(im): extract assertWriteAccess into ChannelsService 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 --- .../src/im/channels/channels.service.spec.ts | 120 ++++++++++++++++++ .../src/im/channels/channels.service.ts | 26 ++++ .../im/messages/messages.controller.spec.ts | 55 +++++--- .../src/im/messages/messages.controller.ts | 25 +--- 4 files changed, 182 insertions(+), 44 deletions(-) diff --git a/apps/server/apps/gateway/src/im/channels/channels.service.spec.ts b/apps/server/apps/gateway/src/im/channels/channels.service.spec.ts index 9c740b4e..173ef734 100644 --- a/apps/server/apps/gateway/src/im/channels/channels.service.spec.ts +++ b/apps/server/apps/gateway/src/im/channels/channels.service.spec.ts @@ -1406,6 +1406,126 @@ describe('ChannelsService', () => { }); }); + // ── assertWriteAccess ───────────────────────────────────────────── + + describe('assertWriteAccess', () => { + let redisService: { getOrSet: MockFn }; + + beforeEach(() => { + redisService = (service as any).redis; + }); + + it('passes for a member of an active, non-archived channel', async () => { + // isMember → getMemberRole → getOrSet returns a role (non-null → member) + redisService.getOrSet = jest.fn().mockResolvedValueOnce('member'); + // findById → getOrSet returns healthy channel + redisService.getOrSet.mockResolvedValueOnce({ + id: 'ch-1', + isActivated: true, + isArchived: false, + }); + + await expect( + service.assertWriteAccess('ch-1', 'user-1'), + ).resolves.toBeUndefined(); + }); + + it('throws "Access denied" for a non-member', async () => { + // isMember → getMemberRole → getOrSet returns null (not a member) + redisService.getOrSet = jest.fn().mockResolvedValueOnce(null); + + await expect(service.assertWriteAccess('ch-1', 'user-1')).rejects.toThrow( + ForbiddenException, + ); + + await expect(service.assertWriteAccess('ch-1', 'user-1')).rejects.toThrow( + 'Access denied', + ); + }); + + it('throws "Access denied" when channel not found', async () => { + // isMember → getMemberRole → getOrSet returns a role (member) + redisService.getOrSet = jest.fn().mockResolvedValueOnce('member'); + // findById → getOrSet returns null (channel not found) + redisService.getOrSet.mockResolvedValueOnce(null); + + await expect(service.assertWriteAccess('ch-1', 'user-1')).rejects.toThrow( + ForbiddenException, + ); + + // Re-run to check the message + redisService.getOrSet = jest.fn().mockResolvedValueOnce('member'); + redisService.getOrSet.mockResolvedValueOnce(null); + await expect(service.assertWriteAccess('ch-1', 'user-1')).rejects.toThrow( + 'Access denied', + ); + }); + + it('throws "deactivated" when channel.isActivated is false', async () => { + // isMember → member + redisService.getOrSet = jest.fn().mockResolvedValueOnce('member'); + // findById → deactivated channel + redisService.getOrSet.mockResolvedValueOnce({ + id: 'ch-1', + isActivated: false, + isArchived: false, + }); + + await expect(service.assertWriteAccess('ch-1', 'user-1')).rejects.toThrow( + ForbiddenException, + ); + + redisService.getOrSet = jest.fn().mockResolvedValueOnce('member'); + redisService.getOrSet.mockResolvedValueOnce({ + id: 'ch-1', + isActivated: false, + isArchived: false, + }); + await expect(service.assertWriteAccess('ch-1', 'user-1')).rejects.toThrow( + 'Channel is deactivated — execution has completed', + ); + }); + + it('throws "archived" when channel.isArchived is true', async () => { + // isMember → member + redisService.getOrSet = jest.fn().mockResolvedValueOnce('member'); + // findById → archived channel + redisService.getOrSet.mockResolvedValueOnce({ + id: 'ch-1', + isActivated: true, + isArchived: true, + }); + + await expect(service.assertWriteAccess('ch-1', 'user-1')).rejects.toThrow( + ForbiddenException, + ); + + redisService.getOrSet = jest.fn().mockResolvedValueOnce('member'); + redisService.getOrSet.mockResolvedValueOnce({ + id: 'ch-1', + isActivated: true, + isArchived: true, + }); + await expect(service.assertWriteAccess('ch-1', 'user-1')).rejects.toThrow( + 'Channel is archived and no longer accepts new messages', + ); + }); + + it('throws "deactivated" (not "archived") when both flags are set', async () => { + // isActivated check must run before isArchived — deactivated wins + redisService.getOrSet = jest.fn().mockResolvedValueOnce('member'); + redisService.getOrSet.mockResolvedValueOnce({ + id: 'ch-1', + isActivated: false, + isArchived: true, + }); + + await expect(service.assertWriteAccess('ch-1', 'user-1')).rejects.toThrow( + 'Channel is deactivated — execution has completed', + ); + }); + }); + describe('getUserChannels', () => { it('attaches other user info for direct channels and keeps public channels unchanged', async () => { db.where diff --git a/apps/server/apps/gateway/src/im/channels/channels.service.ts b/apps/server/apps/gateway/src/im/channels/channels.service.ts index 04ae3b27..6a458749 100644 --- a/apps/server/apps/gateway/src/im/channels/channels.service.ts +++ b/apps/server/apps/gateway/src/im/channels/channels.service.ts @@ -1812,6 +1812,32 @@ export class ChannelsService { throw new ForbiddenException('Access denied'); } + /** + * Asserts the user can post a new message to this channel. + * Throws ForbiddenException with the same human-readable strings the + * messages controller has been throwing inline since the project started. + */ + async assertWriteAccess(channelId: string, userId: string): Promise { + const isMember = await this.isMember(channelId, userId); + if (!isMember) { + throw new ForbiddenException('Access denied'); + } + const channel = await this.findById(channelId); + if (!channel) { + throw new ForbiddenException('Access denied'); + } + if (!channel.isActivated) { + throw new ForbiddenException( + 'Channel is deactivated — execution has completed', + ); + } + if (channel.isArchived) { + throw new ForbiddenException( + 'Channel is archived and no longer accepts new messages', + ); + } + } + /** * Resolve the target of a channel-level model switch. * diff --git a/apps/server/apps/gateway/src/im/messages/messages.controller.spec.ts b/apps/server/apps/gateway/src/im/messages/messages.controller.spec.ts index ad5dd543..71bf35cb 100644 --- a/apps/server/apps/gateway/src/im/messages/messages.controller.spec.ts +++ b/apps/server/apps/gateway/src/im/messages/messages.controller.spec.ts @@ -77,6 +77,7 @@ describe('MessagesController', () => { }; let channelsService: { assertReadAccess: MockFn; + assertWriteAccess: MockFn; isMember: MockFn; findById: MockFn; findByIdOrThrow: MockFn; @@ -147,6 +148,7 @@ describe('MessagesController', () => { channelsService = { assertReadAccess: jest.fn().mockResolvedValue(undefined), + assertWriteAccess: jest.fn().mockResolvedValue(undefined), isMember: jest.fn().mockResolvedValue(true), findById: jest.fn().mockResolvedValue(makeChannel()), findByIdOrThrow: jest.fn().mockResolvedValue({ @@ -269,8 +271,10 @@ describe('MessagesController', () => { }); describe('createMessage', () => { - it('rejects non-members before any message work happens', async () => { - channelsService.isMember.mockResolvedValueOnce(false); + it('rejects non-members (delegates to assertWriteAccess)', async () => { + channelsService.assertWriteAccess.mockRejectedValueOnce( + new ForbiddenException('Access denied'), + ); await expect( controller.createMessage(USER_ID, CHANNEL_ID, { @@ -279,14 +283,19 @@ describe('MessagesController', () => { } as never), ).rejects.toBeInstanceOf(ForbiddenException); - expect(channelsService.findById).not.toHaveBeenCalled(); + expect(channelsService.assertWriteAccess).toHaveBeenCalledWith( + CHANNEL_ID, + USER_ID, + ); expect(imWorkerGrpcClientService.createMessage).not.toHaveBeenCalled(); expect(websocketGateway.sendToChannelMembers).not.toHaveBeenCalled(); }); - it('rejects deactivated channels after membership is confirmed', async () => { - channelsService.findById.mockResolvedValueOnce( - makeChannel({ isActivated: false }), + it('rejects deactivated channels (delegates to assertWriteAccess)', async () => { + channelsService.assertWriteAccess.mockRejectedValueOnce( + new ForbiddenException( + 'Channel is deactivated — execution has completed', + ), ); await expect( @@ -296,16 +305,18 @@ describe('MessagesController', () => { } as never), ).rejects.toBeInstanceOf(ForbiddenException); - expect(channelsService.isMember).toHaveBeenCalledWith( + expect(channelsService.assertWriteAccess).toHaveBeenCalledWith( CHANNEL_ID, USER_ID, ); expect(imWorkerGrpcClientService.createMessage).not.toHaveBeenCalled(); }); - it('rejects archived channels after membership is confirmed', async () => { - channelsService.findById.mockResolvedValueOnce( - makeChannel({ isArchived: true }), + it('rejects archived channels (delegates to assertWriteAccess)', async () => { + channelsService.assertWriteAccess.mockRejectedValueOnce( + new ForbiddenException( + 'Channel is archived and no longer accepts new messages', + ), ); await expect( @@ -315,7 +326,7 @@ describe('MessagesController', () => { } as never), ).rejects.toBeInstanceOf(ForbiddenException); - expect(channelsService.isMember).toHaveBeenCalledWith( + expect(channelsService.assertWriteAccess).toHaveBeenCalledWith( CHANNEL_ID, USER_ID, ); @@ -323,8 +334,10 @@ describe('MessagesController', () => { }); it('archived-channel error message mentions "archived"', async () => { - channelsService.findById.mockResolvedValueOnce( - makeChannel({ isArchived: true }), + channelsService.assertWriteAccess.mockRejectedValueOnce( + new ForbiddenException( + 'Channel is archived and no longer accepts new messages', + ), ); await expect( @@ -335,14 +348,14 @@ describe('MessagesController', () => { ).rejects.toThrow(/archived/i); }); - it('reports deactivated (not archived) when a channel is both deactivated and archived', async () => { - // The controller checks isActivated before isArchived. If both flags - // are set, the deactivation error must win so the operational signal - // (the channel's execution is over) isn't masked by the archive - // state. This test pins the ordering so a future refactor can't - // silently swap the two checks. - channelsService.findById.mockResolvedValueOnce( - makeChannel({ isActivated: false, isArchived: true }), + it('deactivated-channel error message mentions "deactivated"', async () => { + // The deactivation check wins over archive — this is now enforced by + // assertWriteAccess in ChannelsService (tested there). The controller + // test verifies it surfaces the right message when the service throws. + channelsService.assertWriteAccess.mockRejectedValueOnce( + new ForbiddenException( + 'Channel is deactivated — execution has completed', + ), ); await expect( diff --git a/apps/server/apps/gateway/src/im/messages/messages.controller.ts b/apps/server/apps/gateway/src/im/messages/messages.controller.ts index 6a145417..ee86fbac 100644 --- a/apps/server/apps/gateway/src/im/messages/messages.controller.ts +++ b/apps/server/apps/gateway/src/im/messages/messages.controller.ts @@ -82,13 +82,9 @@ export class MessagesController { ): Promise { const t0 = Date.now(); - const isMember = await this.channelsService.isMember(channelId, userId); + await this.channelsService.assertWriteAccess(channelId, userId); const t1 = Date.now(); - if (!isMember) { - throw new ForbiddenException('Access denied'); - } - const clientMsgId = dto.clientMsgId || uuidv7(); // Get workspaceId (tenantId) from channel for message context @@ -96,23 +92,6 @@ export class MessagesController { const t2 = Date.now(); const workspaceId = channel?.tenantId ?? undefined; - // Reject messages to deactivated tracking/task channels - if (channel && !channel.isActivated) { - throw new ForbiddenException( - 'Channel is deactivated — execution has completed', - ); - } - - // Reject messages to archived channels (e.g. one-time routine-creation - // channels archived on finishRoutineCreation). Without this, agent tools - // like SendToChannel and Reply's non-streaming fallback appear successful - // but the message never reaches anyone. - if (channel && channel.isArchived) { - throw new ForbiddenException( - 'Channel is archived and no longer accepts new messages', - ); - } - // Validate @mention permissions (block mentions of restricted personal staff) if (dto.content) { const mentions = parseMentions(dto.content); @@ -227,7 +206,7 @@ export class MessagesController { } const total = Date.now() - t0; - const timing = `isMember=${t1 - t0}ms findById=${t2 - t1}ms gRPC=${t3 - t2}ms getDetails=${t4 - t3}ms total=${total}ms`; + const timing = `assertWriteAccess=${t1 - t0}ms findById=${t2 - t1}ms gRPC=${t3 - t2}ms getDetails=${t4 - t3}ms total=${total}ms`; if (total > 1000) { this.logger.warn( `[createMessage] SLOW channel=${channelId} msgId=${result.msgId} ${timing}`, From 81194e5ab7197813ff0769bd238afe0f3572a8f9 Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 2 May 2026 15:58:43 +0800 Subject: [PATCH 08/23] feat(im): add ForwardsService with snapshot capture and hydration 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 --- .../src/im/channels/channels.service.spec.ts | 49 + .../src/im/channels/channels.service.ts | 42 + .../forwards/forwards.service.spec.ts | 1377 +++++++++++++++++ .../im/messages/forwards/forwards.service.ts | 348 +++++ .../gateway/src/im/messages/forwards/types.ts | 54 + .../src/im/messages/messages.module.ts | 5 +- .../src/im/messages/messages.service.spec.ts | 70 + .../src/im/messages/messages.service.ts | 69 + .../libs/shared/src/types/message.types.ts | 2 +- 9 files changed, 2013 insertions(+), 3 deletions(-) create mode 100644 apps/server/apps/gateway/src/im/messages/forwards/forwards.service.spec.ts create mode 100644 apps/server/apps/gateway/src/im/messages/forwards/forwards.service.ts create mode 100644 apps/server/apps/gateway/src/im/messages/forwards/types.ts diff --git a/apps/server/apps/gateway/src/im/channels/channels.service.spec.ts b/apps/server/apps/gateway/src/im/channels/channels.service.spec.ts index 173ef734..d0ed1a22 100644 --- a/apps/server/apps/gateway/src/im/channels/channels.service.spec.ts +++ b/apps/server/apps/gateway/src/im/channels/channels.service.spec.ts @@ -3064,4 +3064,53 @@ describe('ChannelsService', () => { expect(result).toEqual(new Set(['bot-only'])); }); }); + + // ---- helpers used by ForwardsService ---- + + describe('canRead', () => { + it('returns true when assertReadAccess succeeds', async () => { + jest.spyOn(service, 'assertReadAccess').mockResolvedValueOnce(undefined); + const result = await service.canRead('ch-1', 'user-1'); + expect(result).toBe(true); + }); + + it('returns false when assertReadAccess throws', async () => { + jest + .spyOn(service, 'assertReadAccess') + .mockRejectedValueOnce(new Error('denied')); + const result = await service.canRead('ch-1', 'user-1'); + expect(result).toBe(false); + }); + }); + + describe('findManyByIds', () => { + it('returns empty array for empty channelIds input', async () => { + const result = await service.findManyByIds([]); + expect(result).toEqual([]); + }); + + it('returns channels for provided ids', async () => { + const channelRow = { + id: 'ch-1', + tenantId: null, + name: 'general', + description: null, + type: 'public' as const, + avatarUrl: null, + createdBy: null, + sectionId: null, + order: 0, + isArchived: false, + isActivated: true, + snapshot: null, + propertySettings: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + db.where.mockResolvedValueOnce([channelRow] as any); + const result = await service.findManyByIds(['ch-1']); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('ch-1'); + }); + }); }); diff --git a/apps/server/apps/gateway/src/im/channels/channels.service.ts b/apps/server/apps/gateway/src/im/channels/channels.service.ts index 6a458749..5fe078b7 100644 --- a/apps/server/apps/gateway/src/im/channels/channels.service.ts +++ b/apps/server/apps/gateway/src/im/channels/channels.service.ts @@ -2760,6 +2760,48 @@ export class ChannelsService { await this.tabsService.seedBuiltinTabs(channel.id); return channel; } + + // ---- helpers used by ForwardsService ---- + + /** + * Non-throwing variant of assertReadAccess. + * Returns true iff the user has read access to the channel. + */ + async canRead(channelId: string, userId: string): Promise { + try { + await this.assertReadAccess(channelId, userId); + return true; + } catch { + return false; + } + } + + /** + * Bulk-load channels by IDs. Returns only channels that exist. + */ + async findManyByIds(channelIds: string[]): Promise { + if (channelIds.length === 0) return []; + const rows = await this.db + .select() + .from(schema.channels) + .where(inArray(schema.channels.id, channelIds)); + return rows.map((row) => ({ + id: row.id, + tenantId: row.tenantId, + name: row.name, + description: row.description, + type: row.type, + avatarUrl: row.avatarUrl, + createdBy: row.createdBy, + sectionId: row.sectionId, + order: row.order, + isArchived: row.isArchived, + isActivated: row.isActivated, + snapshot: row.snapshot, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + })); + } } /** diff --git a/apps/server/apps/gateway/src/im/messages/forwards/forwards.service.spec.ts b/apps/server/apps/gateway/src/im/messages/forwards/forwards.service.spec.ts new file mode 100644 index 00000000..dcc6fb8a --- /dev/null +++ b/apps/server/apps/gateway/src/im/messages/forwards/forwards.service.spec.ts @@ -0,0 +1,1377 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; +import { + BadRequestException, + ForbiddenException, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; + +// Mock circular-dep modules BEFORE dynamic imports +jest.unstable_mockModule('../../channels/channels.service.js', () => ({ + ChannelsService: class ChannelsService {}, +})); +jest.unstable_mockModule('../messages.service.js', () => ({ + MessagesService: class MessagesService {}, +})); +jest.unstable_mockModule( + '../../services/im-worker-grpc-client.service.js', + () => ({ + ImWorkerGrpcClientService: class ImWorkerGrpcClientService {}, + }), +); + +const { ForwardsService } = await import('./forwards.service.js'); +const { ChannelsService } = await import('../../channels/channels.service.js'); +const { MessagesService } = await import('../messages.service.js'); +const { ImWorkerGrpcClientService } = + await import('../../services/im-worker-grpc-client.service.js'); +const { DATABASE_CONNECTION } = await import('@team9/database'); + +// ── helpers ────────────────────────────────────────────────────────────────── + +function makeMessage(overrides: Record = {}) { + return { + id: 'msg-1', + channelId: 'ch-src', + senderId: 'user-1', + type: 'text', + content: 'hello', + contentAst: null, + isDeleted: false, + createdAt: new Date('2026-01-01T00:00:00Z'), + seqId: null, + metadata: null, + ...overrides, + }; +} + +function makeChannel(overrides: Record = {}) { + return { + id: 'ch-src', + name: 'general', + tenantId: 'ws-1', + type: 'public', + description: null, + avatarUrl: null, + createdBy: null, + sectionId: null, + order: 0, + isArchived: false, + isActivated: true, + snapshot: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function makeMessageResponse(overrides: Record = {}) { + return { + id: 'fwd-msg-1', + clientMsgId: null, + channelId: 'ch-target', + senderId: 'user-1', + parentId: null, + rootId: null, + content: '[Forwarded] hello', + contentAst: null, + type: 'forward', + isPinned: false, + isEdited: false, + isDeleted: false, + createdAt: new Date(), + updatedAt: new Date(), + sender: null, + attachments: [], + reactions: [], + replyCount: 0, + lastRepliers: [], + lastReplyAt: null, + metadata: { + forward: { + kind: 'single', + count: 1, + sourceChannelId: 'ch-src', + sourceChannelName: 'general', + }, + }, + ...overrides, + }; +} + +function makeForwardRow(overrides: Record = {}) { + return { + id: 'fwd-row-1', + forwardedMessageId: 'fwd-msg-1', + position: 0, + sourceMessageId: 'msg-1', + sourceChannelId: 'ch-src', + sourceWorkspaceId: 'ws-1', + sourceSenderId: 'user-1', + sourceCreatedAt: new Date('2026-01-01T00:00:00Z'), + sourceSeqId: null, + contentSnapshot: 'hello', + contentAstSnapshot: null, + attachmentsSnapshot: [], + sourceType: 'text', + createdAt: new Date(), + ...overrides, + }; +} + +function createDbMock() { + const insertValues = jest.fn().mockResolvedValue([]); + const selectOrderBy = jest.fn().mockResolvedValue([]); + const selectWhere = jest + .fn() + .mockReturnValue({ orderBy: selectOrderBy }); + const selectFrom = jest.fn().mockReturnValue({ where: selectWhere }); + + return { + insert: jest.fn().mockReturnValue({ values: insertValues }), + select: jest.fn().mockReturnValue({ from: selectFrom }), + chains: { + insertValues, + selectFrom, + selectWhere, + selectOrderBy, + }, + }; +} + +// ── suite ───────────────────────────────────────────────────────────────────── + +describe('ForwardsService', () => { + let service: InstanceType; + let channelsService: { + assertReadAccess: jest.Mock; + assertWriteAccess: jest.Mock; + findById: jest.Mock; + findManyByIds: jest.Mock; + canRead: jest.Mock; + }; + let messagesService: { + findManyByIds: jest.Mock; + getMessageWithDetails: jest.Mock; + getMessageChannelId: jest.Mock; + softDelete: jest.Mock; + truncateForPreview: jest.Mock; + getAttachmentsForMessages: jest.Mock; + findUsersByIds: jest.Mock; + }; + let grpcService: { createMessage: jest.Mock }; + let db: ReturnType; + + beforeEach(async () => { + db = createDbMock(); + + channelsService = { + assertReadAccess: jest.fn().mockResolvedValue(undefined), + assertWriteAccess: jest.fn().mockResolvedValue(undefined), + findById: jest.fn().mockResolvedValue(makeChannel()), + findManyByIds: jest.fn().mockResolvedValue([makeChannel()]), + canRead: jest.fn().mockResolvedValue(true), + }; + + messagesService = { + findManyByIds: jest.fn().mockResolvedValue([makeMessage()]), + getMessageWithDetails: jest + .fn() + .mockResolvedValue(makeMessageResponse()), + getMessageChannelId: jest.fn().mockResolvedValue('ch-target'), + softDelete: jest.fn().mockResolvedValue(undefined), + truncateForPreview: jest.fn().mockImplementation((m: any) => m), + getAttachmentsForMessages: jest.fn().mockResolvedValue(new Map()), + findUsersByIds: jest.fn().mockResolvedValue([]), + }; + + grpcService = { + createMessage: jest.fn().mockResolvedValue({ msgId: 'fwd-msg-1' }), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ForwardsService, + { provide: ChannelsService, useValue: channelsService }, + { provide: MessagesService, useValue: messagesService }, + { provide: ImWorkerGrpcClientService, useValue: grpcService }, + { provide: DATABASE_CONNECTION, useValue: db }, + ], + }).compile(); + + service = module.get(ForwardsService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ── validation ─────────────────────────────────────────────────────────── + + describe('validation', () => { + it('rejects empty sourceMessageIds with forward.empty', async () => { + await expect( + service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: [], + userId: 'user-1', + }), + ).rejects.toThrow(BadRequestException); + + await expect( + service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: [], + userId: 'user-1', + }), + ).rejects.toMatchObject({ message: 'forward.empty' }); + }); + + it('rejects sourceMessageIds.length > 100 with forward.tooManySelected', async () => { + const ids = Array.from({ length: 101 }, (_, i) => `msg-${i}`); + await expect( + service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ids, + userId: 'user-1', + }), + ).rejects.toMatchObject({ message: 'forward.tooManySelected' }); + }); + + it('rejects exactly 100 items (boundary — allowed)', async () => { + // 100 is allowed: should NOT throw tooManySelected + const ids = Array.from({ length: 100 }, (_, i) => `msg-${i}`); + messagesService.findManyByIds.mockResolvedValue( + ids.map((id, i) => + makeMessage({ + id, + type: 'text', + channelId: 'ch-src', + content: `msg ${i}`, + }), + ), + ); + messagesService.getMessageWithDetails.mockResolvedValue( + makeMessageResponse(), + ); + // Should reach permissions check, then work + await expect( + service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ids, + userId: 'user-1', + }), + ).resolves.toBeDefined(); + }); + }); + + // ── source eligibility ──────────────────────────────────────────────────── + + describe('source eligibility', () => { + it('rejects system type messages with forward.notAllowed', async () => { + messagesService.findManyByIds.mockResolvedValue([ + makeMessage({ type: 'system' }), + ]); + await expect( + service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1'], + userId: 'user-1', + }), + ).rejects.toMatchObject({ message: 'forward.notAllowed' }); + }); + + it('rejects tracking type messages with forward.notAllowed', async () => { + messagesService.findManyByIds.mockResolvedValue([ + makeMessage({ type: 'tracking' }), + ]); + await expect( + service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1'], + userId: 'user-1', + }), + ).rejects.toMatchObject({ message: 'forward.notAllowed' }); + }); + + it('rejects streaming source (metadata.streaming === true) with forward.notAllowed', async () => { + messagesService.findManyByIds.mockResolvedValue([ + makeMessage({ metadata: { streaming: true } }), + ]); + await expect( + service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1'], + userId: 'user-1', + }), + ).rejects.toMatchObject({ message: 'forward.notAllowed' }); + }); + + it('rejects isDeleted source with forward.notAllowed', async () => { + messagesService.findManyByIds.mockResolvedValue([ + makeMessage({ isDeleted: true }), + ]); + await expect( + service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1'], + userId: 'user-1', + }), + ).rejects.toMatchObject({ message: 'forward.notAllowed' }); + }); + + it('rejects source not found (count mismatch) with forward.notFound', async () => { + messagesService.findManyByIds.mockResolvedValue([]); // 0 returned, 1 requested + await expect( + service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1'], + userId: 'user-1', + }), + ).rejects.toMatchObject({ message: 'forward.notFound' }); + }); + + it('rejects mixed source channels with forward.mixedChannels', async () => { + messagesService.findManyByIds.mockResolvedValue([ + makeMessage({ id: 'msg-1', channelId: 'ch-src' }), + makeMessage({ id: 'msg-2', channelId: 'ch-other' }), + ]); + await expect( + service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1', 'msg-2'], + userId: 'user-1', + }), + ).rejects.toMatchObject({ message: 'forward.mixedChannels' }); + }); + }); + + // ── permissions ─────────────────────────────────────────────────────────── + + describe('permissions', () => { + it('throws forward.noSourceAccess when assertReadAccess fails on source', async () => { + channelsService.assertReadAccess.mockRejectedValue( + new ForbiddenException('Access denied'), + ); + await expect( + service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1'], + userId: 'user-1', + }), + ).rejects.toMatchObject({ message: 'forward.noSourceAccess' }); + }); + + it('throws forward.noWriteAccess when assertWriteAccess fails on target', async () => { + channelsService.assertWriteAccess.mockRejectedValue( + new ForbiddenException('Access denied'), + ); + await expect( + service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1'], + userId: 'user-1', + }), + ).rejects.toMatchObject({ message: 'forward.noWriteAccess' }); + }); + }); + + // ── single forward happy paths ──────────────────────────────────────────── + + describe('single forward', () => { + it('forwards a single text message and returns forward type response', async () => { + const result = await service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1'], + userId: 'user-1', + }); + + expect(result.type).toBe('forward'); + expect(grpcService.createMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: 'forward', attachments: undefined }), + ); + }); + + it('forwards a single image message with attachment snapshot', async () => { + messagesService.findManyByIds.mockResolvedValue([ + makeMessage({ type: 'image', content: null }), + ]); + const attachmentMap = new Map([ + [ + 'msg-1', + [ + { + id: 'att-1', + messageId: 'msg-1', + fileName: 'photo.jpg', + fileUrl: 'https://example.com/photo.jpg', + fileKey: 'key-1', + fileSize: 12345, + mimeType: 'image/jpeg', + thumbnailUrl: 'https://example.com/thumb.jpg', + width: 800, + height: 600, + createdAt: new Date(), + }, + ], + ], + ]); + messagesService.getAttachmentsForMessages.mockResolvedValue( + attachmentMap, + ); + + await service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1'], + userId: 'user-1', + }); + + // The forward message should be created with attachments: undefined + expect(grpcService.createMessage).toHaveBeenCalledWith( + expect.objectContaining({ attachments: undefined }), + ); + // The insert values should include the attachment in attachmentsSnapshot + const insertArgs = db.chains.insertValues.mock.calls[0][0] as any[]; + expect(insertArgs[0].attachmentsSnapshot).toHaveLength(1); + expect(insertArgs[0].attachmentsSnapshot[0].originalAttachmentId).toBe( + 'att-1', + ); + }); + + it('forwards a single long_text message', async () => { + messagesService.findManyByIds.mockResolvedValue([ + makeMessage({ type: 'long_text', content: 'A'.repeat(3000) }), + ]); + + const result = await service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1'], + userId: 'user-1', + }); + + expect(result).toBeDefined(); + }); + + it('forwards a single re-forward (source type === forward) with no attachments and no AST snapshot', async () => { + messagesService.findManyByIds.mockResolvedValue([ + makeMessage({ + type: 'forward', + content: '[Forwarded] original', + contentAst: { root: {} }, + }), + ]); + const attachmentMap = new Map([ + [ + 'msg-1', + [ + { + id: 'att-1', + messageId: 'msg-1', + fileName: 'file.txt', + fileUrl: 'https://x', + fileKey: null, + fileSize: 100, + mimeType: 'text/plain', + thumbnailUrl: null, + width: null, + height: null, + createdAt: new Date(), + }, + ], + ], + ]); + messagesService.getAttachmentsForMessages.mockResolvedValue( + attachmentMap, + ); + + await service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1'], + userId: 'user-1', + }); + + const insertArgs = db.chains.insertValues.mock.calls[0][0] as any[]; + expect(insertArgs[0].contentAstSnapshot).toBeNull(); + expect(insertArgs[0].attachmentsSnapshot).toHaveLength(0); + expect(insertArgs[0].sourceType).toBe('forward'); + }); + + it('uses clientMsgId from input when provided', async () => { + await service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1'], + clientMsgId: 'my-client-id', + userId: 'user-1', + }); + + expect(grpcService.createMessage).toHaveBeenCalledWith( + expect.objectContaining({ clientMsgId: 'my-client-id' }), + ); + }); + + it('generates a clientMsgId when not provided', async () => { + await service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1'], + userId: 'user-1', + }); + + const call = grpcService.createMessage.mock.calls[0][0] as any; + expect(typeof call.clientMsgId).toBe('string'); + expect(call.clientMsgId).toBeTruthy(); + }); + + it('includes metadata.forward in the grpc createMessage call', async () => { + await service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1'], + userId: 'user-1', + }); + + const call = grpcService.createMessage.mock.calls[0][0] as any; + expect(call.metadata?.forward?.kind).toBe('single'); + expect(call.metadata?.forward?.sourceChannelId).toBe('ch-src'); + }); + + it('uses tenantId from targetChannel as workspaceId', async () => { + channelsService.findById + .mockResolvedValueOnce( + makeChannel({ id: 'ch-src', tenantId: 'ws-src' }), + ) // source channel + .mockResolvedValueOnce( + makeChannel({ id: 'ch-target', tenantId: 'ws-target' }), + ); // target channel + + await service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1'], + userId: 'user-1', + }); + + const call = grpcService.createMessage.mock.calls[0][0] as any; + expect(call.workspaceId).toBe('ws-target'); + }); + + it('handles targetChannel with null tenantId (workspaceId undefined)', async () => { + channelsService.findById + .mockResolvedValueOnce(makeChannel({ id: 'ch-src', tenantId: null })) // source channel + .mockResolvedValueOnce( + makeChannel({ id: 'ch-target', tenantId: null }), + ); // target channel + + await service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1'], + userId: 'user-1', + }); + + const call = grpcService.createMessage.mock.calls[0][0] as any; + expect(call.workspaceId).toBeUndefined(); + }); + }); + + // ── bundle forward ──────────────────────────────────────────────────────── + + describe('bundle forward', () => { + it('forwards 5 mixed-type messages as a bundle with ordered positions', async () => { + const ids = ['m1', 'm2', 'm3', 'm4', 'm5']; + const types = ['text', 'image', 'long_text', 'file', 'forward'] as const; + messagesService.findManyByIds.mockResolvedValue( + ids.map((id, i) => + makeMessage({ id, type: types[i], content: `content ${i}` }), + ), + ); + messagesService.getMessageWithDetails.mockResolvedValue( + makeMessageResponse({ id: 'fwd-bundle' }), + ); + + await service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ids, + userId: 'user-1', + }); + + const call = grpcService.createMessage.mock.calls[0][0] as any; + expect(call.metadata?.forward?.kind).toBe('bundle'); + expect(call.metadata?.forward?.count).toBe(5); + + const insertArgs = db.chains.insertValues.mock.calls[0][0] as any[]; + expect(insertArgs).toHaveLength(5); + insertArgs.forEach((row: any, i: number) => { + expect(row.position).toBe(i); + }); + }); + + it('builds bundle digest with source channel name', async () => { + const ids = ['m1', 'm2', 'm3']; + messagesService.findManyByIds.mockResolvedValue( + ids.map((id, i) => makeMessage({ id, content: `msg ${i}` })), + ); + channelsService.findById.mockResolvedValue( + makeChannel({ name: 'general' }), + ); + + await service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ids, + userId: 'user-1', + }); + + const call = grpcService.createMessage.mock.calls[0][0] as any; + expect(call.content).toContain('[Forwarded chat record'); + expect(call.content).toContain('general'); + }); + + it('uses "channel" fallback when sourceChannelName is null', async () => { + channelsService.findById.mockResolvedValue(null); + const ids = ['m1', 'm2']; + messagesService.findManyByIds.mockResolvedValue( + ids.map((id) => makeMessage({ id })), + ); + + await service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ids, + userId: 'user-1', + }); + + const call = grpcService.createMessage.mock.calls[0][0] as any; + expect(call.content).toContain('#channel'); + }); + }); + + // ── truncation ──────────────────────────────────────────────────────────── + + describe('truncation', () => { + it('truncates contentSnapshot to 100_000 chars and sets truncated=true', async () => { + const longContent = 'A'.repeat(100_001); + messagesService.findManyByIds.mockResolvedValue([ + makeMessage({ content: longContent }), + ]); + + await service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1'], + userId: 'user-1', + }); + + const insertArgs = db.chains.insertValues.mock.calls[0][0] as any[]; + expect(insertArgs[0].contentSnapshot).toHaveLength(100_000); + + // metadata should have truncated: true + const call = grpcService.createMessage.mock.calls[0][0] as any; + expect(call.metadata?.forward?.truncated).toBe(true); + }); + + it('does NOT set truncated flag when content length is exactly 100_000', async () => { + const exactContent = 'A'.repeat(100_000); + messagesService.findManyByIds.mockResolvedValue([ + makeMessage({ content: exactContent }), + ]); + + await service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1'], + userId: 'user-1', + }); + + const call = grpcService.createMessage.mock.calls[0][0] as any; + expect(call.metadata?.forward?.truncated).toBeUndefined(); + }); + }); + + // ── attachments ─────────────────────────────────────────────────────────── + + describe('attachments', () => { + it('calls grpc.createMessage with attachments: undefined', async () => { + await service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1'], + userId: 'user-1', + }); + + expect(grpcService.createMessage).toHaveBeenCalledWith( + expect.objectContaining({ attachments: undefined }), + ); + }); + + it('includes attachment snapshot fields from original attachments', async () => { + messagesService.findManyByIds.mockResolvedValue([ + makeMessage({ type: 'file' }), + ]); + const attachmentMap = new Map([ + [ + 'msg-1', + [ + { + id: 'att-original', + messageId: 'msg-1', + fileName: 'report.pdf', + fileUrl: 'https://example.com/report.pdf', + fileKey: 'filekey-123', + fileSize: 98765, + mimeType: 'application/pdf', + thumbnailUrl: null, + width: null, + height: null, + createdAt: new Date(), + }, + ], + ], + ]); + messagesService.getAttachmentsForMessages.mockResolvedValue( + attachmentMap, + ); + + await service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1'], + userId: 'user-1', + }); + + const insertArgs = db.chains.insertValues.mock.calls[0][0] as any[]; + expect(insertArgs[0].attachmentsSnapshot[0]).toMatchObject({ + originalAttachmentId: 'att-original', + fileName: 'report.pdf', + fileKey: 'filekey-123', + }); + }); + + it('handles attachment with null fileKey', async () => { + messagesService.findManyByIds.mockResolvedValue([ + makeMessage({ type: 'file' }), + ]); + const attachmentMap = new Map([ + [ + 'msg-1', + [ + { + id: 'att-1', + messageId: 'msg-1', + fileName: 'ext.pdf', + fileUrl: 'https://external.com/file.pdf', + fileKey: null, // external file — no fileKey + fileSize: 500, + mimeType: 'application/pdf', + thumbnailUrl: null, + width: null, + height: null, + createdAt: new Date(), + }, + ], + ], + ]); + messagesService.getAttachmentsForMessages.mockResolvedValue( + attachmentMap, + ); + + await service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1'], + userId: 'user-1', + }); + + const insertArgs = db.chains.insertValues.mock.calls[0][0] as any[]; + expect(insertArgs[0].attachmentsSnapshot[0].fileKey).toBeNull(); + }); + }); + + // ── failed insert rollback ───────────────────────────────────────────────── + + describe('failed insert rollback', () => { + it('soft-deletes the forward message and throws InternalServerErrorException when db.insert fails', async () => { + db.chains.insertValues.mockRejectedValue(new Error('DB error')); + + await expect( + service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1'], + userId: 'user-1', + }), + ).rejects.toMatchObject({ message: 'forward.insertFailed' }); + + await expect( + service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1'], + userId: 'user-1', + }), + ).rejects.toBeInstanceOf(InternalServerErrorException); + + expect(messagesService.softDelete).toHaveBeenCalled(); + }); + }); + + // ── getForwardItems ─────────────────────────────────────────────────────── + + describe('getForwardItems', () => { + it('enforces assertReadAccess on the channel containing the forward message', async () => { + db.chains.selectOrderBy.mockResolvedValue([makeForwardRow()]); + + await service.getForwardItems('fwd-msg-1', 'user-1'); + + expect(messagesService.getMessageChannelId).toHaveBeenCalledWith( + 'fwd-msg-1', + ); + expect(channelsService.assertReadAccess).toHaveBeenCalledWith( + 'ch-target', + 'user-1', + ); + }); + + it('returns ordered ForwardItemResponse[]', async () => { + db.chains.selectOrderBy.mockResolvedValue([ + makeForwardRow({ position: 0, sourceMessageId: 'msg-1' }), + makeForwardRow({ + id: 'fwd-row-2', + position: 1, + sourceMessageId: 'msg-2', + }), + ]); + channelsService.findManyByIds.mockResolvedValue([makeChannel()]); + messagesService.findManyByIds.mockResolvedValue([ + makeMessage({ id: 'msg-1' }), + makeMessage({ id: 'msg-2' }), + ]); + messagesService.findUsersByIds.mockResolvedValue([ + { + id: 'user-1', + username: 'alice', + displayName: 'Alice', + avatarUrl: null, + }, + ]); + + const items = await service.getForwardItems('fwd-msg-1', 'user-1'); + + expect(items).toHaveLength(2); + expect(items[0].position).toBe(0); + expect(items[1].position).toBe(1); + }); + + it('returns [] for an unknown forward message id (no rows)', async () => { + db.chains.selectOrderBy.mockResolvedValue([]); + + const items = await service.getForwardItems('unknown-id', 'user-1'); + + expect(items).toEqual([]); + }); + }); + + // ── hydratePayload ──────────────────────────────────────────────────────── + + describe('hydratePayload', () => { + it('composes payload from items and metadata', async () => { + db.chains.selectOrderBy.mockResolvedValue([makeForwardRow()]); + channelsService.findManyByIds.mockResolvedValue([makeChannel()]); + messagesService.findManyByIds.mockResolvedValue([makeMessage()]); + messagesService.findUsersByIds.mockResolvedValue([]); + + const payload = await service.hydratePayload('fwd-msg-1', 'user-1', { + kind: 'single', + count: 1, + sourceChannelId: 'ch-src', + sourceChannelName: 'general', + }); + + expect(payload.kind).toBe('single'); + expect(payload.count).toBe(1); + expect(payload.sourceChannelId).toBe('ch-src'); + expect(payload.sourceChannelName).toBe('general'); + expect(Array.isArray(payload.items)).toBe(true); + }); + + it('uses metadata.truncated when set', async () => { + db.chains.selectOrderBy.mockResolvedValue([makeForwardRow()]); + channelsService.findManyByIds.mockResolvedValue([makeChannel()]); + messagesService.findManyByIds.mockResolvedValue([makeMessage()]); + messagesService.findUsersByIds.mockResolvedValue([]); + + const payload = await service.hydratePayload('fwd-msg-1', 'user-1', { + kind: 'single', + count: 1, + sourceChannelId: 'ch-src', + sourceChannelName: 'general', + truncated: true, + }); + + expect(payload.truncated).toBe(true); + }); + + it('falls back to items.some(i => i.truncated) when metadata.truncated is undefined', async () => { + const truncatedContent = 'A'.repeat(100_000); + db.chains.selectOrderBy.mockResolvedValue([ + makeForwardRow({ contentSnapshot: truncatedContent }), + ]); + channelsService.findManyByIds.mockResolvedValue([makeChannel()]); + messagesService.findManyByIds.mockResolvedValue([makeMessage()]); + messagesService.findUsersByIds.mockResolvedValue([]); + + const payload = await service.hydratePayload('fwd-msg-1', 'user-1', { + kind: 'single', + count: 1, + sourceChannelId: 'ch-src', + sourceChannelName: 'general', + // no truncated field + }); + + expect(payload.truncated).toBe(true); + }); + + it('sourceChannelName is null when metadata.sourceChannelName is empty string', async () => { + db.chains.selectOrderBy.mockResolvedValue([]); + + const payload = await service.hydratePayload('fwd-msg-1', 'user-1', { + kind: 'single', + count: 1, + sourceChannelId: 'ch-src', + sourceChannelName: '', // empty string + }); + + expect(payload.sourceChannelName).toBeNull(); + }); + }); + + // ── hydrateItems (hydrate) ──────────────────────────────────────────────── + + describe('hydrateItems (canJumpToOriginal + truncated)', () => { + it('canJumpToOriginal is true when source exists and user can read source channel', async () => { + db.chains.selectOrderBy.mockResolvedValue([ + makeForwardRow({ sourceMessageId: 'msg-1' }), + ]); + channelsService.findManyByIds.mockResolvedValue([makeChannel()]); + messagesService.findManyByIds.mockResolvedValue([ + makeMessage({ id: 'msg-1', isDeleted: false }), + ]); + messagesService.findUsersByIds.mockResolvedValue([]); + channelsService.canRead.mockResolvedValue(true); + + const items = await service.hydrateItems('fwd-msg-1', 'user-1'); + + expect(items[0].canJumpToOriginal).toBe(true); + }); + + it('canJumpToOriginal is false when source message is hard-deleted (not in liveSources)', async () => { + db.chains.selectOrderBy.mockResolvedValue([ + makeForwardRow({ sourceMessageId: 'msg-1' }), + ]); + channelsService.findManyByIds.mockResolvedValue([makeChannel()]); + // Source message not returned (hard deleted) + messagesService.findManyByIds.mockResolvedValue([]); + messagesService.findUsersByIds.mockResolvedValue([]); + channelsService.canRead.mockResolvedValue(true); + + const items = await service.hydrateItems('fwd-msg-1', 'user-1'); + + expect(items[0].canJumpToOriginal).toBe(false); + }); + + it('canJumpToOriginal is false when source message is soft-deleted (isDeleted=true)', async () => { + db.chains.selectOrderBy.mockResolvedValue([ + makeForwardRow({ sourceMessageId: 'msg-1' }), + ]); + channelsService.findManyByIds.mockResolvedValue([makeChannel()]); + messagesService.findManyByIds.mockResolvedValue([ + makeMessage({ id: 'msg-1', isDeleted: true }), + ]); + messagesService.findUsersByIds.mockResolvedValue([]); + channelsService.canRead.mockResolvedValue(true); + + const items = await service.hydrateItems('fwd-msg-1', 'user-1'); + + expect(items[0].canJumpToOriginal).toBe(false); + }); + + it('canJumpToOriginal is false when user has no read access to source channel', async () => { + db.chains.selectOrderBy.mockResolvedValue([ + makeForwardRow({ sourceMessageId: 'msg-1' }), + ]); + channelsService.findManyByIds.mockResolvedValue([makeChannel()]); + messagesService.findManyByIds.mockResolvedValue([ + makeMessage({ id: 'msg-1', isDeleted: false }), + ]); + messagesService.findUsersByIds.mockResolvedValue([]); + channelsService.canRead.mockResolvedValue(false); + + const items = await service.hydrateItems('fwd-msg-1', 'user-1'); + + expect(items[0].canJumpToOriginal).toBe(false); + }); + + it('canJumpToOriginal is false when sourceMessageId is null', async () => { + db.chains.selectOrderBy.mockResolvedValue([ + makeForwardRow({ sourceMessageId: null }), + ]); + channelsService.findManyByIds.mockResolvedValue([makeChannel()]); + messagesService.findManyByIds.mockResolvedValue([]); + messagesService.findUsersByIds.mockResolvedValue([]); + channelsService.canRead.mockResolvedValue(true); + + const items = await service.hydrateItems('fwd-msg-1', 'user-1'); + + expect(items[0].canJumpToOriginal).toBe(false); + }); + + it('sourceChannelName is null when user cannot read source channel', async () => { + db.chains.selectOrderBy.mockResolvedValue([ + makeForwardRow({ sourceMessageId: null }), + ]); + channelsService.findManyByIds.mockResolvedValue([ + makeChannel({ name: 'secret' }), + ]); + messagesService.findManyByIds.mockResolvedValue([]); + messagesService.findUsersByIds.mockResolvedValue([]); + channelsService.canRead.mockResolvedValue(false); + + const items = await service.hydrateItems('fwd-msg-1', 'user-1'); + + expect(items[0].sourceChannelName).toBeNull(); + }); + + it('truncated is true when contentSnapshot.length === 100_000', async () => { + const exactContent = 'A'.repeat(100_000); + db.chains.selectOrderBy.mockResolvedValue([ + makeForwardRow({ contentSnapshot: exactContent }), + ]); + channelsService.findManyByIds.mockResolvedValue([makeChannel()]); + messagesService.findManyByIds.mockResolvedValue([makeMessage()]); + messagesService.findUsersByIds.mockResolvedValue([]); + + const items = await service.hydrateItems('fwd-msg-1', 'user-1'); + + expect(items[0].truncated).toBe(true); + }); + + it('truncated is false when contentSnapshot.length < 100_000', async () => { + db.chains.selectOrderBy.mockResolvedValue([ + makeForwardRow({ contentSnapshot: 'short content' }), + ]); + channelsService.findManyByIds.mockResolvedValue([makeChannel()]); + messagesService.findManyByIds.mockResolvedValue([makeMessage()]); + messagesService.findUsersByIds.mockResolvedValue([]); + + const items = await service.hydrateItems('fwd-msg-1', 'user-1'); + + expect(items[0].truncated).toBe(false); + }); + + it('truncated is false when contentSnapshot is null', async () => { + db.chains.selectOrderBy.mockResolvedValue([ + makeForwardRow({ contentSnapshot: null }), + ]); + channelsService.findManyByIds.mockResolvedValue([makeChannel()]); + messagesService.findManyByIds.mockResolvedValue([makeMessage()]); + messagesService.findUsersByIds.mockResolvedValue([]); + + const items = await service.hydrateItems('fwd-msg-1', 'user-1'); + + expect(items[0].truncated).toBe(false); + }); + + it('sourceSender is null when sourceSenderId is null', async () => { + db.chains.selectOrderBy.mockResolvedValue([ + makeForwardRow({ sourceSenderId: null }), + ]); + channelsService.findManyByIds.mockResolvedValue([makeChannel()]); + messagesService.findManyByIds.mockResolvedValue([makeMessage()]); + messagesService.findUsersByIds.mockResolvedValue([]); + + const items = await service.hydrateItems('fwd-msg-1', 'user-1'); + + expect(items[0].sourceSender).toBeNull(); + }); + + it('sourceSender is null when sender not found in senderMap', async () => { + db.chains.selectOrderBy.mockResolvedValue([ + makeForwardRow({ sourceSenderId: 'user-unknown' }), + ]); + channelsService.findManyByIds.mockResolvedValue([makeChannel()]); + messagesService.findManyByIds.mockResolvedValue([makeMessage()]); + // findUsersByIds returns empty (user deleted/not found) + messagesService.findUsersByIds.mockResolvedValue([]); + + const items = await service.hydrateItems('fwd-msg-1', 'user-1'); + + expect(items[0].sourceSender).toBeNull(); + }); + + it('sourceSeqId is null when row.sourceSeqId is null', async () => { + db.chains.selectOrderBy.mockResolvedValue([ + makeForwardRow({ sourceSeqId: null }), + ]); + channelsService.findManyByIds.mockResolvedValue([makeChannel()]); + messagesService.findManyByIds.mockResolvedValue([makeMessage()]); + messagesService.findUsersByIds.mockResolvedValue([]); + + const items = await service.hydrateItems('fwd-msg-1', 'user-1'); + + expect(items[0].sourceSeqId).toBeNull(); + }); + + it('sourceSeqId is a string when row.sourceSeqId is a BigInt', async () => { + db.chains.selectOrderBy.mockResolvedValue([ + makeForwardRow({ sourceSeqId: BigInt(42) }), + ]); + channelsService.findManyByIds.mockResolvedValue([makeChannel()]); + messagesService.findManyByIds.mockResolvedValue([makeMessage()]); + messagesService.findUsersByIds.mockResolvedValue([]); + + const items = await service.hydrateItems('fwd-msg-1', 'user-1'); + + expect(items[0].sourceSeqId).toBe('42'); + }); + + it('returns empty array when there are no rows for the forwardedMessageId', async () => { + db.chains.selectOrderBy.mockResolvedValue([]); + + const items = await service.hydrateItems('fwd-msg-1', 'user-1'); + + expect(items).toEqual([]); + }); + + it('handles rows with no sourceSenderIds (skips findUsersByIds for empty array)', async () => { + db.chains.selectOrderBy.mockResolvedValue([ + makeForwardRow({ sourceSenderId: null }), + ]); + channelsService.findManyByIds.mockResolvedValue([makeChannel()]); + messagesService.findManyByIds.mockResolvedValue([]); + messagesService.findUsersByIds.mockResolvedValue([]); + + const items = await service.hydrateItems('fwd-msg-1', 'user-1'); + + expect(items).toHaveLength(1); + }); + + it('handles rows with no sourceMessageIds (skips findManyByIds for sources)', async () => { + db.chains.selectOrderBy.mockResolvedValue([ + makeForwardRow({ sourceMessageId: null, sourceSenderId: null }), + ]); + channelsService.findManyByIds.mockResolvedValue([makeChannel()]); + messagesService.findManyByIds.mockResolvedValue([]); + messagesService.findUsersByIds.mockResolvedValue([]); + + const items = await service.hydrateItems('fwd-msg-1', 'user-1'); + + expect(items).toHaveLength(1); + }); + + it('sourceChannelName is null when channel not found in channelMap', async () => { + db.chains.selectOrderBy.mockResolvedValue([ + makeForwardRow({ sourceChannelId: 'ch-missing' }), + ]); + // findManyByIds returns empty (channel deleted) + channelsService.findManyByIds.mockResolvedValue([]); + messagesService.findManyByIds.mockResolvedValue([makeMessage()]); + messagesService.findUsersByIds.mockResolvedValue([]); + channelsService.canRead.mockResolvedValue(true); + + const items = await service.hydrateItems('fwd-msg-1', 'user-1'); + + expect(items[0].sourceChannelName).toBeNull(); + }); + + it('sourceWorkspaceId is set from row when not null', async () => { + db.chains.selectOrderBy.mockResolvedValue([ + makeForwardRow({ sourceWorkspaceId: 'ws-abc' }), + ]); + channelsService.findManyByIds.mockResolvedValue([makeChannel()]); + messagesService.findManyByIds.mockResolvedValue([makeMessage()]); + messagesService.findUsersByIds.mockResolvedValue([]); + + const items = await service.hydrateItems('fwd-msg-1', 'user-1'); + + expect(items[0].sourceWorkspaceId).toBe('ws-abc'); + }); + + it('sourceSender includes displayName when not null', async () => { + db.chains.selectOrderBy.mockResolvedValue([ + makeForwardRow({ sourceSenderId: 'user-1' }), + ]); + channelsService.findManyByIds.mockResolvedValue([makeChannel()]); + messagesService.findManyByIds.mockResolvedValue([makeMessage()]); + messagesService.findUsersByIds.mockResolvedValue([ + { + id: 'user-1', + username: 'alice', + displayName: 'Alice Smith', + avatarUrl: 'https://example.com/avatar.jpg', + }, + ]); + + const items = await service.hydrateItems('fwd-msg-1', 'user-1'); + + expect(items[0].sourceSender?.displayName).toBe('Alice Smith'); + expect(items[0].sourceSender?.avatarUrl).toBe( + 'https://example.com/avatar.jpg', + ); + }); + + it('attachmentsSnapshot uses row value when not null', async () => { + const snapshot = [ + { + originalAttachmentId: 'att-1', + fileName: 'f.png', + fileUrl: 'https://x', + fileKey: null, + fileSize: 100, + mimeType: 'image/png', + thumbnailUrl: null, + width: null, + height: null, + }, + ]; + db.chains.selectOrderBy.mockResolvedValue([ + makeForwardRow({ attachmentsSnapshot: snapshot }), + ]); + channelsService.findManyByIds.mockResolvedValue([makeChannel()]); + messagesService.findManyByIds.mockResolvedValue([makeMessage()]); + messagesService.findUsersByIds.mockResolvedValue([]); + + const items = await service.hydrateItems('fwd-msg-1', 'user-1'); + + expect(items[0].attachmentsSnapshot).toHaveLength(1); + expect(items[0].attachmentsSnapshot[0].originalAttachmentId).toBe( + 'att-1', + ); + }); + + it('attachmentsSnapshot falls back to empty array when row.attachmentsSnapshot is null', async () => { + db.chains.selectOrderBy.mockResolvedValue([ + makeForwardRow({ attachmentsSnapshot: null }), + ]); + channelsService.findManyByIds.mockResolvedValue([makeChannel()]); + messagesService.findManyByIds.mockResolvedValue([makeMessage()]); + messagesService.findUsersByIds.mockResolvedValue([]); + + const items = await service.hydrateItems('fwd-msg-1', 'user-1'); + + expect(items[0].attachmentsSnapshot).toEqual([]); + }); + + it('sourceWorkspaceId is null when row.sourceWorkspaceId is null', async () => { + db.chains.selectOrderBy.mockResolvedValue([ + makeForwardRow({ sourceWorkspaceId: null }), + ]); + channelsService.findManyByIds.mockResolvedValue([makeChannel()]); + messagesService.findManyByIds.mockResolvedValue([makeMessage()]); + messagesService.findUsersByIds.mockResolvedValue([]); + + const items = await service.hydrateItems('fwd-msg-1', 'user-1'); + + expect(items[0].sourceWorkspaceId).toBeNull(); + }); + + it('sourceSender.displayName is null when displayName is null', async () => { + db.chains.selectOrderBy.mockResolvedValue([ + makeForwardRow({ sourceSenderId: 'user-nullname' }), + ]); + channelsService.findManyByIds.mockResolvedValue([makeChannel()]); + messagesService.findManyByIds.mockResolvedValue([makeMessage()]); + // Sender with null displayName + messagesService.findUsersByIds.mockResolvedValue([ + { + id: 'user-nullname', + username: 'no-display', + displayName: null, + avatarUrl: null, + }, + ]); + + const items = await service.hydrateItems('fwd-msg-1', 'user-1'); + + expect(items[0].sourceSender?.displayName).toBeNull(); + expect(items[0].sourceSender?.avatarUrl).toBeNull(); + }); + + it('accessByChannel defaults to false when channel is not in the map (no canRead call)', async () => { + // Simulate a channel that is in rows but canRead is not called for it + // (i.e., distinctChannelIds has it but accessByChannel might not be populated) + // In practice, accessByChannel always gets populated because we await all calls. + // This tests the `?? false` fallback by providing an empty canRead mock that + // doesn't populate for the channel in question. + const originalCanRead = channelsService.canRead; + // Make canRead do nothing (doesn't call the map setter) + channelsService.canRead = jest.fn().mockImplementation(async () => { + // Deliberately don't populate — but Promise.all will still run + return false; + }); + db.chains.selectOrderBy.mockResolvedValue([ + makeForwardRow({ sourceChannelId: 'ch-test', sourceMessageId: null }), + ]); + channelsService.findManyByIds.mockResolvedValue([ + makeChannel({ id: 'ch-test' }), + ]); + messagesService.findManyByIds.mockResolvedValue([]); + messagesService.findUsersByIds.mockResolvedValue([]); + + const items = await service.hydrateItems('fwd-msg-1', 'user-1'); + + expect(items[0].canJumpToOriginal).toBe(false); + channelsService.canRead = originalCanRead; + }); + }); + + // ── buildDigest branch: bundle with null content items ─────────────────── + + describe('buildDigest null content in bundle', () => { + it('handles null content in bundle sources by substituting empty string', async () => { + const ids = ['m1', 'm2']; + messagesService.findManyByIds.mockResolvedValue( + ids.map((id) => makeMessage({ id, content: null })), + ); + channelsService.findById.mockResolvedValue( + makeChannel({ name: 'general' }), + ); + + await service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ids, + userId: 'user-1', + }); + + const call = grpcService.createMessage.mock.calls[0][0] as any; + // Content should use empty string for null content items + expect(call.content).toContain('[Forwarded chat record'); + }); + }); +}); diff --git a/apps/server/apps/gateway/src/im/messages/forwards/forwards.service.ts b/apps/server/apps/gateway/src/im/messages/forwards/forwards.service.ts new file mode 100644 index 00000000..47e50fda --- /dev/null +++ b/apps/server/apps/gateway/src/im/messages/forwards/forwards.service.ts @@ -0,0 +1,348 @@ +import { + Injectable, + Logger, + BadRequestException, + NotFoundException, + InternalServerErrorException, + ForbiddenException, + Inject, +} from '@nestjs/common'; +import { v7 as uuidv7 } from 'uuid'; +import { + eq, + DATABASE_CONNECTION, + type PostgresJsDatabase, +} from '@team9/database'; +import * as schema from '@team9/database/schemas'; +import type { + ForwardAttachmentSnapshot, + NewMessageForward, +} from '@team9/database/schemas'; +import { ChannelsService } from '../../channels/channels.service.js'; +import { MessagesService, type MessageResponse } from '../messages.service.js'; +import { ImWorkerGrpcClientService } from '../../services/im-worker-grpc-client.service.js'; +import { + FORWARD_BUNDLE_LIMIT, + FORWARD_CONTENT_SNAPSHOT_LIMIT, + FORWARDABLE_SOURCE_TYPES, + type ForwardItemResponse, + type ForwardKind, + type ForwardMetadata, + type ForwardPayload, +} from './types.js'; + +export interface ForwardInput { + targetChannelId: string; + sourceChannelId: string; + sourceMessageIds: string[]; + clientMsgId?: string; + userId: string; +} + +@Injectable() +export class ForwardsService { + private readonly logger = new Logger(ForwardsService.name); + + constructor( + private readonly channelsService: ChannelsService, + private readonly messagesService: MessagesService, + private readonly grpc: ImWorkerGrpcClientService, + @Inject(DATABASE_CONNECTION) + private readonly db: PostgresJsDatabase, + ) {} + + async forward(input: ForwardInput): Promise { + const { targetChannelId, sourceChannelId, sourceMessageIds, userId } = + input; + + // --- Validation --- + if (sourceMessageIds.length === 0) { + throw new BadRequestException('forward.empty'); + } + if (sourceMessageIds.length > FORWARD_BUNDLE_LIMIT) { + throw new BadRequestException('forward.tooManySelected'); + } + + // --- Permission checks --- + try { + await this.channelsService.assertReadAccess(sourceChannelId, userId); + } catch { + throw new ForbiddenException('forward.noSourceAccess'); + } + try { + await this.channelsService.assertWriteAccess(targetChannelId, userId); + } catch { + throw new ForbiddenException('forward.noWriteAccess'); + } + + // --- Load source messages --- + const sourceMessages = + await this.messagesService.findManyByIds(sourceMessageIds); + if (sourceMessages.length !== sourceMessageIds.length) { + throw new NotFoundException('forward.notFound'); + } + + // --- Validate each source message --- + for (const m of sourceMessages) { + if (m.channelId !== sourceChannelId) { + throw new BadRequestException('forward.mixedChannels'); + } + if (m.isDeleted) { + throw new BadRequestException('forward.notAllowed'); + } + if (!FORWARDABLE_SOURCE_TYPES.has(m.type)) { + throw new BadRequestException('forward.notAllowed'); + } + const meta = m.metadata ?? {}; + if (meta.streaming === true) { + throw new BadRequestException('forward.notAllowed'); + } + } + + // Preserve the original ordering from the input IDs + const ordered = sourceMessageIds.map((id) => { + const m = sourceMessages.find((s) => s.id === id)!; + return m; + }); + + // --- Load attachments for source messages --- + const attachmentsByMessage = + await this.messagesService.getAttachmentsForMessages( + ordered.map((m) => m.id), + ); + + // --- Load source channel info --- + const sourceChannel = await this.channelsService.findById(sourceChannelId); + const sourceChannelName = sourceChannel?.name ?? null; + + const kind: ForwardKind = ordered.length === 1 ? 'single' : 'bundle'; + + // --- Build forward rows --- + const items: Array<{ + row: Omit; + truncated: boolean; + }> = ordered.map((m, position) => { + const attachments = (attachmentsByMessage.get(m.id) ?? []).map( + (a): ForwardAttachmentSnapshot => ({ + originalAttachmentId: a.id, + fileName: a.fileName, + fileUrl: a.fileUrl, + fileKey: a.fileKey ?? null, + fileSize: a.fileSize, + mimeType: a.mimeType, + thumbnailUrl: a.thumbnailUrl ?? null, + width: a.width ?? null, + height: a.height ?? null, + }), + ); + + let snapshot = m.content ?? null; + let truncated = false; + if (snapshot && snapshot.length > FORWARD_CONTENT_SNAPSHOT_LIMIT) { + snapshot = snapshot.slice(0, FORWARD_CONTENT_SNAPSHOT_LIMIT); + truncated = true; + } + + // For re-forwarded messages: use content (digest) as snapshot, no AST, no attachments + const isReForward = m.type === 'forward'; + + return { + truncated, + row: { + forwardedMessageId: '__placeholder__', // patched after createMessage + position, + sourceMessageId: m.id, + sourceChannelId, + sourceWorkspaceId: sourceChannel?.tenantId ?? null, + sourceSenderId: m.senderId, + sourceCreatedAt: m.createdAt, + sourceSeqId: m.seqId ?? null, + contentSnapshot: snapshot, + contentAstSnapshot: isReForward ? null : (m.contentAst ?? null), + attachmentsSnapshot: isReForward ? [] : attachments, + sourceType: m.type, + }, + }; + }); + + const anyTruncated = items.some((i) => i.truncated); + const digest = this.buildDigest(kind, ordered, sourceChannelName); + const metadataForward: ForwardMetadata = { + kind, + count: ordered.length, + sourceChannelId, + sourceChannelName: sourceChannelName ?? '', + ...(anyTruncated && { truncated: true }), + }; + + // --- Create the forward message via gRPC --- + const targetChannel = await this.channelsService.findById(targetChannelId); + const created = await this.grpc.createMessage({ + clientMsgId: input.clientMsgId ?? uuidv7(), + channelId: targetChannelId, + senderId: userId, + content: digest, + type: 'forward', + workspaceId: targetChannel?.tenantId ?? undefined, + attachments: undefined, + metadata: { forward: metadataForward }, + }); + + const forwardedMessageId = created.msgId; + + // --- Insert forward snapshot rows --- + try { + await this.db + .insert(schema.messageForwards) + .values(items.map((i) => ({ ...i.row, forwardedMessageId }))); + } catch (err) { + this.logger.error( + `Failed to insert forward rows for ${forwardedMessageId}: ${String(err)}`, + ); + await this.messagesService.softDelete(forwardedMessageId, userId); + throw new InternalServerErrorException('forward.insertFailed'); + } + + const message = + await this.messagesService.getMessageWithDetails(forwardedMessageId); + return this.messagesService.truncateForPreview(message); + } + + /** + * Get ordered forward items for a bundle-viewer endpoint. + * Enforces read access on the forward message's channel. + */ + async getForwardItems( + forwardedMessageId: string, + userId: string, + ): Promise { + const channelId = + await this.messagesService.getMessageChannelId(forwardedMessageId); + await this.channelsService.assertReadAccess(channelId, userId); + return this.hydrateItems(forwardedMessageId, userId); + } + + /** + * Internal: hydrate forward rows into ForwardItemResponse[]. + * Does NOT enforce access — callers must check before calling. + */ + async hydrateItems( + forwardedMessageId: string, + userId: string, + ): Promise { + const rows = await this.db + .select() + .from(schema.messageForwards) + .where(eq(schema.messageForwards.forwardedMessageId, forwardedMessageId)) + .orderBy(schema.messageForwards.position); + + if (rows.length === 0) return []; + + const distinctChannelIds = Array.from( + new Set(rows.map((r) => r.sourceChannelId)), + ); + const distinctSenderIds = Array.from( + new Set( + rows.map((r) => r.sourceSenderId).filter((x): x is string => !!x), + ), + ); + const distinctSourceMsgIds = rows + .map((r) => r.sourceMessageId) + .filter((x): x is string => !!x); + + const [channels, senders, liveSources] = await Promise.all([ + this.channelsService.findManyByIds(distinctChannelIds), + this.messagesService.findUsersByIds(distinctSenderIds), + distinctSourceMsgIds.length > 0 + ? this.messagesService.findManyByIds(distinctSourceMsgIds) + : Promise.resolve([]), + ]); + + const channelMap = new Map(channels.map((c) => [c.id, c])); + const senderMap = new Map(senders.map((u) => [u.id, u])); + const liveSourceIds = new Set( + liveSources.filter((m) => !m.isDeleted).map((m) => m.id), + ); + + // Check read access per channel (non-throwing) + const accessByChannel = new Map(); + await Promise.all( + distinctChannelIds.map(async (cid) => { + const ok = await this.channelsService.canRead(cid, userId); + accessByChannel.set(cid, ok); + }), + ); + + return rows.map((r): ForwardItemResponse => { + const ch = channelMap.get(r.sourceChannelId); + const sender = r.sourceSenderId ? senderMap.get(r.sourceSenderId) : null; + /* istanbul ignore next -- accessByChannel always populated via Promise.all above */ + const userCanReadSource = accessByChannel.get(r.sourceChannelId) ?? false; + const sourceStillExists = + !!r.sourceMessageId && liveSourceIds.has(r.sourceMessageId); + const truncated = + !!r.contentSnapshot && + r.contentSnapshot.length === FORWARD_CONTENT_SNAPSHOT_LIMIT; + + return { + position: r.position, + sourceMessageId: r.sourceMessageId ?? null, + sourceChannelId: r.sourceChannelId, + sourceChannelName: userCanReadSource ? (ch?.name ?? null) : null, + sourceWorkspaceId: r.sourceWorkspaceId ?? null, + sourceSender: sender + ? { + id: sender.id, + username: sender.username, + displayName: sender.displayName ?? null, + avatarUrl: sender.avatarUrl ?? null, + } + : null, + sourceCreatedAt: r.sourceCreatedAt.toISOString(), + sourceSeqId: r.sourceSeqId !== null ? r.sourceSeqId.toString() : null, + sourceType: r.sourceType as ForwardItemResponse['sourceType'], + contentSnapshot: r.contentSnapshot ?? null, + contentAstSnapshot: r.contentAstSnapshot ?? null, + attachmentsSnapshot: r.attachmentsSnapshot ?? [], + canJumpToOriginal: sourceStillExists && userCanReadSource, + truncated, + }; + }); + } + + /** + * Hydrate a ForwardPayload for consumption by MessagesService (Task 5). + */ + async hydratePayload( + forwardedMessageId: string, + userId: string, + metadataForward: ForwardMetadata, + ): Promise { + const items = await this.hydrateItems(forwardedMessageId, userId); + return { + kind: metadataForward.kind, + count: metadataForward.count, + sourceChannelId: metadataForward.sourceChannelId, + sourceChannelName: metadataForward.sourceChannelName || null, + truncated: metadataForward.truncated ?? items.some((i) => i.truncated), + items, + }; + } + + private buildDigest( + kind: ForwardKind, + sources: { content: string | null; senderId: string | null }[], + channelName: string | null, + ): string { + if (kind === 'single') { + const m = sources[0]; + const head = (m.content ?? '').slice(0, 200); + return `[Forwarded] ${head}`; + } + const previews = sources + .slice(0, 3) + .map((m) => (m.content ?? '').slice(0, 80)) + .join('; '); + return `[Forwarded chat record · ${sources.length} messages from #${channelName ?? 'channel'}] ${previews}`; + } +} diff --git a/apps/server/apps/gateway/src/im/messages/forwards/types.ts b/apps/server/apps/gateway/src/im/messages/forwards/types.ts new file mode 100644 index 00000000..d0c4a8b8 --- /dev/null +++ b/apps/server/apps/gateway/src/im/messages/forwards/types.ts @@ -0,0 +1,54 @@ +import type { ForwardAttachmentSnapshot } from '@team9/database'; + +export type ForwardKind = 'single' | 'bundle'; + +export interface ForwardSourceUser { + id: string; + username: string; + displayName: string | null; + avatarUrl: string | null; +} + +export interface ForwardItemResponse { + position: number; + sourceMessageId: string | null; + sourceChannelId: string; + sourceChannelName: string | null; + sourceWorkspaceId: string | null; + sourceSender: ForwardSourceUser | null; + sourceCreatedAt: string; + sourceSeqId: string | null; + sourceType: 'text' | 'long_text' | 'file' | 'image' | 'forward'; + contentSnapshot: string | null; + contentAstSnapshot: Record | null; + attachmentsSnapshot: ForwardAttachmentSnapshot[]; + canJumpToOriginal: boolean; + truncated: boolean; +} + +export interface ForwardPayload { + kind: ForwardKind; + count: number; + sourceChannelId: string; + sourceChannelName: string | null; + truncated: boolean; + items: ForwardItemResponse[]; +} + +export interface ForwardMetadata { + kind: ForwardKind; + count: number; + sourceChannelId: string; + sourceChannelName: string; + truncated?: boolean; +} + +export const FORWARD_CONTENT_SNAPSHOT_LIMIT = 100_000; +export const FORWARD_BUNDLE_LIMIT = 100; +export const FORWARDABLE_SOURCE_TYPES: ReadonlySet = new Set([ + 'text', + 'long_text', + 'file', + 'image', + 'forward', +]); diff --git a/apps/server/apps/gateway/src/im/messages/messages.module.ts b/apps/server/apps/gateway/src/im/messages/messages.module.ts index fb649c00..50055f59 100644 --- a/apps/server/apps/gateway/src/im/messages/messages.module.ts +++ b/apps/server/apps/gateway/src/im/messages/messages.module.ts @@ -2,6 +2,7 @@ import { Module, forwardRef } from '@nestjs/common'; import { RedisModule } from '@team9/redis'; import { MessagesController } from './messages.controller.js'; import { MessagesService } from './messages.service.js'; +import { ForwardsService } from './forwards/forwards.service.js'; import { AuthModule } from '../../auth/auth.module.js'; import { ChannelsModule } from '../channels/channels.module.js'; import { WebsocketModule } from '../websocket/websocket.module.js'; @@ -18,7 +19,7 @@ import { StreamingController } from '../streaming/streaming.controller.js'; forwardRef(() => WebsocketModule), ], controllers: [MessagesController, StreamingController], - providers: [MessagesService, ImWorkerGrpcClientService], - exports: [MessagesService, ImWorkerGrpcClientService], + providers: [MessagesService, ImWorkerGrpcClientService, ForwardsService], + exports: [MessagesService, ImWorkerGrpcClientService, ForwardsService], }) export class MessagesModule {} diff --git a/apps/server/apps/gateway/src/im/messages/messages.service.spec.ts b/apps/server/apps/gateway/src/im/messages/messages.service.spec.ts index deccb36c..3e9b7220 100644 --- a/apps/server/apps/gateway/src/im/messages/messages.service.spec.ts +++ b/apps/server/apps/gateway/src/im/messages/messages.service.spec.ts @@ -1985,4 +1985,74 @@ describe('MessagesService', () => { expect.objectContaining({ isDeleted: true }), ); }); + + // ---- helpers used by ForwardsService ---- + + it('findManyByIds returns empty array for empty input', async () => { + const result = await service.findManyByIds([]); + expect(result).toEqual([]); + expect(db.select).not.toHaveBeenCalled(); + }); + + it('findManyByIds calls db.select with inArray filter', async () => { + db.chains.selectWhere.mockResolvedValueOnce([makeMessageRow()]); + const result = await service.findManyByIds(['msg-1']); + expect(result).toHaveLength(1); + expect(db.select).toHaveBeenCalled(); + }); + + it('getAttachmentsForMessages returns empty map for empty input', async () => { + const result = await service.getAttachmentsForMessages([]); + expect(result.size).toBe(0); + expect(db.select).not.toHaveBeenCalled(); + }); + + it('getAttachmentsForMessages groups attachments by messageId', async () => { + db.chains.selectWhere.mockResolvedValueOnce([ + { + id: 'att-1', + messageId: 'msg-1', + fileName: 'file.txt', + fileUrl: 'https://x', + fileKey: null, + fileSize: 100, + mimeType: 'text/plain', + thumbnailUrl: null, + width: null, + height: null, + createdAt: new Date(), + }, + ]); + const result = await service.getAttachmentsForMessages(['msg-1']); + expect(result.get('msg-1')).toHaveLength(1); + }); + + it('findUsersByIds returns empty array for empty input', async () => { + const result = await service.findUsersByIds([]); + expect(result).toEqual([]); + expect(db.select).not.toHaveBeenCalled(); + }); + + it('findUsersByIds returns user fields for provided ids', async () => { + db.chains.selectWhere.mockResolvedValueOnce([ + { + id: 'user-1', + username: 'alice', + displayName: 'Alice', + avatarUrl: null, + }, + ]); + const result = await service.findUsersByIds(['user-1']); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('user-1'); + }); + + it('softDelete sets isDeleted=true and does not throw', async () => { + await expect( + service.softDelete('msg-1', 'user-1'), + ).resolves.toBeUndefined(); + expect(db.chains.updateSet).toHaveBeenCalledWith( + expect.objectContaining({ isDeleted: true }), + ); + }); }); diff --git a/apps/server/apps/gateway/src/im/messages/messages.service.ts b/apps/server/apps/gateway/src/im/messages/messages.service.ts index 1ce94e96..3211cc9e 100644 --- a/apps/server/apps/gateway/src/im/messages/messages.service.ts +++ b/apps/server/apps/gateway/src/im/messages/messages.service.ts @@ -1174,6 +1174,75 @@ export class MessagesService { return { ...message, content, isTruncated, fullContentLength }; } + // ---- helpers used by ForwardsService ---- + + /** + * Bulk-load raw message rows by IDs. No joins — returns only the raw columns. + */ + async findManyByIds(ids: string[]): Promise { + if (ids.length === 0) return []; + return this.db + .select() + .from(schema.messages) + .where(inArray(schema.messages.id, ids)); + } + + /** + * Bulk-load attachments for multiple messages, grouped by messageId. + */ + async getAttachmentsForMessages( + messageIds: string[], + ): Promise> { + const result = new Map(); + if (messageIds.length === 0) return result; + const rows = await this.db + .select() + .from(schema.messageAttachments) + .where(inArray(schema.messageAttachments.messageId, messageIds)); + for (const row of rows) { + const existing = result.get(row.messageId) ?? []; + existing.push(row); + result.set(row.messageId, existing); + } + return result; + } + + /** + * Bulk-load minimal user info needed by forward renderers. + */ + async findUsersByIds( + userIds: string[], + ): Promise< + Pick[] + > { + if (userIds.length === 0) return []; + return this.db + .select({ + id: schema.users.id, + username: schema.users.username, + displayName: schema.users.displayName, + avatarUrl: schema.users.avatarUrl, + }) + .from(schema.users) + .where(inArray(schema.users.id, userIds)); + } + + /** + * Soft-delete a message without ownership checks. + * Used by ForwardsService rollback when forward-row insert fails. + */ + async softDelete(messageId: string, userId: string): Promise { + await this.db + .update(schema.messages) + .set({ + isDeleted: true, + deletedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(schema.messages.id, messageId)); + void userId; // userId reserved for future audit logging + } + async getFullContent(messageId: string): Promise<{ content: string }> { const [message] = await this.db .select({ diff --git a/apps/server/libs/shared/src/types/message.types.ts b/apps/server/libs/shared/src/types/message.types.ts index 9b30ca52..279ef285 100644 --- a/apps/server/libs/shared/src/types/message.types.ts +++ b/apps/server/libs/shared/src/types/message.types.ts @@ -266,7 +266,7 @@ export interface CreateMessageDto { rootId?: string; // Message type - type: 'text' | 'file' | 'image' | 'long_text'; + type: 'text' | 'file' | 'image' | 'long_text' | 'forward'; // File attachments attachments?: CreateMessageAttachmentDto[]; From 2c50b1f4c15375b905779ec74c2b0673050e7c73 Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 2 May 2026 16:04:20 +0800 Subject: [PATCH 09/23] feat(im): add forward REST endpoints 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 --- .../forwards/dto/create-forward.dto.ts | 23 +++++ .../forwards/forwards.controller.spec.ts | 86 +++++++++++++++++++ .../messages/forwards/forwards.controller.ts | 43 ++++++++++ .../src/im/messages/messages.module.ts | 3 +- 4 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 apps/server/apps/gateway/src/im/messages/forwards/dto/create-forward.dto.ts create mode 100644 apps/server/apps/gateway/src/im/messages/forwards/forwards.controller.spec.ts create mode 100644 apps/server/apps/gateway/src/im/messages/forwards/forwards.controller.ts diff --git a/apps/server/apps/gateway/src/im/messages/forwards/dto/create-forward.dto.ts b/apps/server/apps/gateway/src/im/messages/forwards/dto/create-forward.dto.ts new file mode 100644 index 00000000..25e8d562 --- /dev/null +++ b/apps/server/apps/gateway/src/im/messages/forwards/dto/create-forward.dto.ts @@ -0,0 +1,23 @@ +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; + +export class CreateForwardDto { + @IsUUID() + sourceChannelId!: string; + + @IsArray() + @ArrayMinSize(1, { message: 'forward.empty' }) + @ArrayMaxSize(100, { message: 'forward.tooManySelected' }) + @IsUUID('all', { each: true }) + sourceMessageIds!: string[]; + + @IsOptional() + @IsString() + clientMsgId?: string; +} diff --git a/apps/server/apps/gateway/src/im/messages/forwards/forwards.controller.spec.ts b/apps/server/apps/gateway/src/im/messages/forwards/forwards.controller.spec.ts new file mode 100644 index 00000000..468e91f5 --- /dev/null +++ b/apps/server/apps/gateway/src/im/messages/forwards/forwards.controller.spec.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { Test, type TestingModule } from '@nestjs/testing'; + +// Mock ForwardsService before dynamic import of controller +jest.unstable_mockModule('./forwards.service.js', () => ({ + ForwardsService: class ForwardsService {}, +})); + +// Mock @team9/auth so AuthGuard can be resolved + overridden +jest.unstable_mockModule('@team9/auth', () => ({ + AuthGuard: class AuthGuard {}, + CurrentUser: () => () => {}, +})); + +const { ForwardsController } = await import('./forwards.controller.js'); +const { ForwardsService } = await import('./forwards.service.js'); +const { AuthGuard } = await import('@team9/auth'); + +type MockFn = jest.Mock<(...args: any[]) => any>; + +describe('ForwardsController', () => { + let controller: InstanceType; + let svc: { forward: MockFn; getForwardItems: MockFn }; + + beforeEach(async () => { + svc = { + forward: jest.fn(), + getForwardItems: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [ForwardsController], + providers: [ + { + provide: ForwardsService, + useValue: svc, + }, + ], + }) + .overrideGuard(AuthGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(ForwardsController); + }); + + describe('POST forward', () => { + it('delegates to ForwardsService.forward with the right args', async () => { + svc.forward.mockResolvedValueOnce({ id: 'm1', type: 'forward' } as never); + const res = await controller.forward('u-1', 'ch-target', { + sourceChannelId: 'ch-src', + sourceMessageIds: ['m-a', 'm-b'], + clientMsgId: 'cid', + }); + expect(svc.forward).toHaveBeenCalledWith({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['m-a', 'm-b'], + clientMsgId: 'cid', + userId: 'u-1', + }); + expect((res as { id: string }).id).toBe('m1'); + }); + + it('passes undefined clientMsgId when not provided', async () => { + svc.forward.mockResolvedValueOnce({ id: 'm2' } as never); + await controller.forward('u-1', 'ch-target', { + sourceChannelId: 'ch-src', + sourceMessageIds: ['m-a'], + }); + expect(svc.forward).toHaveBeenCalledWith( + expect.objectContaining({ clientMsgId: undefined }), + ); + }); + }); + + describe('GET forward-items', () => { + it('delegates to ForwardsService.getForwardItems', async () => { + const items = [{ position: 0 }] as never; + svc.getForwardItems.mockResolvedValueOnce(items); + const res = await controller.getItems('u-1', 'msg-1'); + expect(svc.getForwardItems).toHaveBeenCalledWith('msg-1', 'u-1'); + expect(res).toBe(items); + }); + }); +}); diff --git a/apps/server/apps/gateway/src/im/messages/forwards/forwards.controller.ts b/apps/server/apps/gateway/src/im/messages/forwards/forwards.controller.ts new file mode 100644 index 00000000..bbc515e9 --- /dev/null +++ b/apps/server/apps/gateway/src/im/messages/forwards/forwards.controller.ts @@ -0,0 +1,43 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard, CurrentUser } from '@team9/auth'; +import { ForwardsService } from './forwards.service.js'; +import { CreateForwardDto } from './dto/create-forward.dto.js'; +import type { MessageResponse } from '../messages.service.js'; +import type { ForwardItemResponse } from './types.js'; + +@Controller({ path: 'im', version: '1' }) +@UseGuards(AuthGuard) +export class ForwardsController { + constructor(private readonly forwardsService: ForwardsService) {} + + @Post('channels/:targetChannelId/forward') + async forward( + @CurrentUser('sub') userId: string, + @Param('targetChannelId', ParseUUIDPipe) targetChannelId: string, + @Body() dto: CreateForwardDto, + ): Promise { + return this.forwardsService.forward({ + targetChannelId, + sourceChannelId: dto.sourceChannelId, + sourceMessageIds: dto.sourceMessageIds, + clientMsgId: dto.clientMsgId, + userId, + }); + } + + @Get('messages/:id/forward-items') + async getItems( + @CurrentUser('sub') userId: string, + @Param('id', ParseUUIDPipe) messageId: string, + ): Promise { + return this.forwardsService.getForwardItems(messageId, userId); + } +} diff --git a/apps/server/apps/gateway/src/im/messages/messages.module.ts b/apps/server/apps/gateway/src/im/messages/messages.module.ts index 50055f59..cfda5a19 100644 --- a/apps/server/apps/gateway/src/im/messages/messages.module.ts +++ b/apps/server/apps/gateway/src/im/messages/messages.module.ts @@ -3,6 +3,7 @@ import { RedisModule } from '@team9/redis'; import { MessagesController } from './messages.controller.js'; import { MessagesService } from './messages.service.js'; import { ForwardsService } from './forwards/forwards.service.js'; +import { ForwardsController } from './forwards/forwards.controller.js'; import { AuthModule } from '../../auth/auth.module.js'; import { ChannelsModule } from '../channels/channels.module.js'; import { WebsocketModule } from '../websocket/websocket.module.js'; @@ -18,7 +19,7 @@ import { StreamingController } from '../streaming/streaming.controller.js'; forwardRef(() => ChannelsModule), forwardRef(() => WebsocketModule), ], - controllers: [MessagesController, StreamingController], + controllers: [MessagesController, StreamingController, ForwardsController], providers: [MessagesService, ImWorkerGrpcClientService, ForwardsService], exports: [MessagesService, ImWorkerGrpcClientService, ForwardsService], }) From a8cd3882bf6d8525dd514f04bc11d9fc715ffee4 Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 2 May 2026 16:21:16 +0800 Subject: [PATCH 10/23] feat(im): hydrate forward payload on message reads; reject PATCH on forwards - 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 --- .../im/messages/forwards/forwards.service.ts | 2 + .../im/messages/messages.controller.spec.ts | 24 ++ .../src/im/messages/messages.controller.ts | 10 +- .../src/im/messages/messages.module.ts | 11 +- .../src/im/messages/messages.service.spec.ts | 255 +++++++++++++++++- .../src/im/messages/messages.service.ts | 127 +++++++-- .../src/shared/constants/injection-tokens.ts | 1 + 7 files changed, 404 insertions(+), 26 deletions(-) diff --git a/apps/server/apps/gateway/src/im/messages/forwards/forwards.service.ts b/apps/server/apps/gateway/src/im/messages/forwards/forwards.service.ts index 47e50fda..2c484e20 100644 --- a/apps/server/apps/gateway/src/im/messages/forwards/forwards.service.ts +++ b/apps/server/apps/gateway/src/im/messages/forwards/forwards.service.ts @@ -6,6 +6,7 @@ import { InternalServerErrorException, ForbiddenException, Inject, + forwardRef, } from '@nestjs/common'; import { v7 as uuidv7 } from 'uuid'; import { @@ -45,6 +46,7 @@ export class ForwardsService { constructor( private readonly channelsService: ChannelsService, + @Inject(forwardRef(() => MessagesService)) private readonly messagesService: MessagesService, private readonly grpc: ImWorkerGrpcClientService, @Inject(DATABASE_CONNECTION) diff --git a/apps/server/apps/gateway/src/im/messages/messages.controller.spec.ts b/apps/server/apps/gateway/src/im/messages/messages.controller.spec.ts index 71bf35cb..328f96c8 100644 --- a/apps/server/apps/gateway/src/im/messages/messages.controller.spec.ts +++ b/apps/server/apps/gateway/src/im/messages/messages.controller.spec.ts @@ -239,6 +239,7 @@ describe('MessagesController', () => { CHANNEL_ID, 50, 'cursor-1', + USER_ID, ); expect( messagesService.getChannelMessagesPaginated, @@ -265,6 +266,7 @@ describe('MessagesController', () => { CHANNEL_ID, 25, { before: undefined, after: 'after-cursor', around: undefined }, + USER_ID, ); expect(messagesService.getChannelMessages).not.toHaveBeenCalled(); }); @@ -766,6 +768,7 @@ describe('MessagesController', () => { expect(messagesService.getMessageWithDetails).toHaveBeenCalledWith( MESSAGE_ID, + USER_ID, ); expect(channelsService.assertReadAccess).toHaveBeenCalledWith( CHANNEL_ID, @@ -865,6 +868,7 @@ describe('MessagesController', () => { MESSAGE_ID, 12, 'cursor-12', + USER_ID, ); }); @@ -881,6 +885,7 @@ describe('MessagesController', () => { MESSAGE_ID, 20, 'cursor-2', + USER_ID, ); }); }); @@ -991,4 +996,23 @@ describe('MessagesController', () => { expect(messagesService.getFullContent).toHaveBeenCalledWith(MESSAGE_ID); }); }); + + describe('updateMessage — forward guard', () => { + it('propagates forward.editDisabled from service when PATCHing a forward-type message', async () => { + messagesService.update.mockRejectedValueOnce( + new BadRequestException('forward.editDisabled'), + ); + + await expect( + controller.updateMessage(USER_ID, MESSAGE_ID, { + content: 'x', + } as never), + ).rejects.toThrow('forward.editDisabled'); + + expect(messagesService.update).toHaveBeenCalledWith(MESSAGE_ID, USER_ID, { + content: 'x', + }); + expect(websocketGateway.sendToChannelMembers).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/server/apps/gateway/src/im/messages/messages.controller.ts b/apps/server/apps/gateway/src/im/messages/messages.controller.ts index ee86fbac..49956269 100644 --- a/apps/server/apps/gateway/src/im/messages/messages.controller.ts +++ b/apps/server/apps/gateway/src/im/messages/messages.controller.ts @@ -148,6 +148,7 @@ export class MessagesController { // Fetch the full message details for response const message = await this.messagesService.getMessageWithDetails( result.msgId, + userId, ); const t4 = Date.now(); @@ -290,6 +291,7 @@ export class MessagesController { channelId, parsedLimit, { before, after, around }, + userId, ); return { ...paginated, @@ -303,6 +305,7 @@ export class MessagesController { channelId, parsedLimit, before, + userId, ); const total = Date.now() - t0; @@ -350,7 +353,10 @@ export class MessagesController { @CurrentUser('sub') userId: string, @Param('id', ParseUUIDPipe) messageId: string, ): Promise { - const message = await this.messagesService.getMessageWithDetails(messageId); + const message = await this.messagesService.getMessageWithDetails( + messageId, + userId, + ); await this.channelsService.assertReadAccess(message.channelId, userId); return this.messagesService.truncateForPreview(message); } @@ -451,6 +457,7 @@ export class MessagesController { messageId, limit ? parseInt(limit, 10) : 20, cursor, + userId, ); const tp = (m: MessageResponse) => this.messagesService.truncateForPreview(m); @@ -482,6 +489,7 @@ export class MessagesController { messageId, limit ? parseInt(limit, 10) : 20, cursor, + userId, ); return { ...subReplies, diff --git a/apps/server/apps/gateway/src/im/messages/messages.module.ts b/apps/server/apps/gateway/src/im/messages/messages.module.ts index cfda5a19..f31dfd3d 100644 --- a/apps/server/apps/gateway/src/im/messages/messages.module.ts +++ b/apps/server/apps/gateway/src/im/messages/messages.module.ts @@ -10,6 +10,7 @@ import { WebsocketModule } from '../websocket/websocket.module.js'; import { PropertiesModule } from '../properties/properties.module.js'; import { ImWorkerGrpcClientService } from '../services/im-worker-grpc-client.service.js'; import { StreamingController } from '../streaming/streaming.controller.js'; +import { FORWARDS_SERVICE } from '../../shared/constants/injection-tokens.js'; @Module({ imports: [ @@ -20,7 +21,15 @@ import { StreamingController } from '../streaming/streaming.controller.js'; forwardRef(() => WebsocketModule), ], controllers: [MessagesController, StreamingController, ForwardsController], - providers: [MessagesService, ImWorkerGrpcClientService, ForwardsService], + providers: [ + MessagesService, + ImWorkerGrpcClientService, + ForwardsService, + { + provide: FORWARDS_SERVICE, + useExisting: ForwardsService, + }, + ], exports: [MessagesService, ImWorkerGrpcClientService, ForwardsService], }) export class MessagesModule {} diff --git a/apps/server/apps/gateway/src/im/messages/messages.service.spec.ts b/apps/server/apps/gateway/src/im/messages/messages.service.spec.ts index 3e9b7220..a3fef10e 100644 --- a/apps/server/apps/gateway/src/im/messages/messages.service.spec.ts +++ b/apps/server/apps/gateway/src/im/messages/messages.service.spec.ts @@ -21,9 +21,17 @@ jest.unstable_mockModule( ImWorkerGrpcClientService: class ImWorkerGrpcClientService {}, }), ); +// Mock ForwardsService to break the circular import chain +jest.unstable_mockModule('./forwards/forwards.service.js', () => ({ + ForwardsService: class ForwardsService {}, +})); + +const WEBSOCKET_GATEWAY_TOKEN = Symbol('WEBSOCKET_GATEWAY'); +const FORWARDS_SERVICE_TOKEN = Symbol('FORWARDS_SERVICE'); jest.unstable_mockModule('../../shared/constants/injection-tokens.js', () => ({ - WEBSOCKET_GATEWAY: Symbol('WEBSOCKET_GATEWAY'), + WEBSOCKET_GATEWAY: WEBSOCKET_GATEWAY_TOKEN, + FORWARDS_SERVICE: FORWARDS_SERVICE_TOKEN, })); const { MessagesService } = await import('./messages.service.js'); @@ -138,6 +146,11 @@ describe('MessagesService', () => { let mockWsGateway: { emitRelationsPurged: jest.Mock; }; + let forwardsService: { + hydratePayload: jest.Mock; + forward: jest.Mock; + getForwardItems: jest.Mock; + }; let logger: { warn: jest.Mock; error: jest.Mock; @@ -157,8 +170,16 @@ describe('MessagesService', () => { mockWsGateway = { emitRelationsPurged: jest.fn().mockResolvedValue(undefined), }; + forwardsService = { + hydratePayload: jest.fn().mockResolvedValue(undefined), + forward: jest.fn().mockResolvedValue(undefined), + getForwardItems: jest.fn().mockResolvedValue([]), + }; const moduleRef = { - get: jest.fn().mockReturnValue(mockWsGateway), + get: jest.fn().mockImplementation((token: unknown) => { + if (token === FORWARDS_SERVICE_TOKEN) return forwardsService; + return mockWsGateway; + }), }; service = new MessagesService( db as never, @@ -2055,4 +2076,234 @@ describe('MessagesService', () => { expect.objectContaining({ isDeleted: true }), ); }); + + // ---- forward hydration tests ---- + + describe('forward hydration', () => { + function makeSelectChain(returnValue: unknown) { + return { + from: jest.fn().mockReturnValue({ + leftJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnValue(returnValue), + }), + }; + } + + function setupForwardMessageMocks(metadata: Record) { + const messageRow = makeMessageRow({ + id: 'm-fwd', + type: 'forward', + metadata, + }); + db.select + .mockReturnValueOnce( + makeSelectChain({ + limit: jest.fn().mockResolvedValue([messageRow]), + }), + ) + // getSendersByIds (sender lookup) + .mockReturnValueOnce(makeSelectChain([])) + // attachments + .mockReturnValueOnce(makeSelectChain([])) + // reactions + .mockReturnValueOnce(makeSelectChain([])) + // reply count + .mockReturnValueOnce(makeSelectChain([{ count: 0 }])) + // recent repliers + .mockReturnValueOnce( + makeSelectChain({ + orderBy: jest.fn().mockResolvedValue([]), + }), + ); + } + + function setupTextMessageMocks() { + const messageRow = makeMessageRow({ id: 'm-text', type: 'text' }); + db.select + .mockReturnValueOnce( + makeSelectChain({ + limit: jest.fn().mockResolvedValue([messageRow]), + }), + ) + .mockReturnValueOnce(makeSelectChain([])) + .mockReturnValueOnce(makeSelectChain([])) + .mockReturnValueOnce(makeSelectChain([])) + .mockReturnValueOnce(makeSelectChain([{ count: 0 }])) + .mockReturnValueOnce( + makeSelectChain({ + orderBy: jest.fn().mockResolvedValue([]), + }), + ); + } + + it('attaches forward payload for type=forward messages', async () => { + const fakePayload = { + kind: 'single', + count: 1, + items: [], + sourceChannelId: 'c', + sourceChannelName: null, + truncated: false, + }; + forwardsService.hydratePayload.mockResolvedValueOnce( + fakePayload as never, + ); + + setupForwardMessageMocks({ + forward: { + kind: 'single', + count: 1, + sourceChannelId: 'c', + sourceChannelName: '', + }, + }); + + const m = await service.getMessageWithDetails('m-fwd', 'u-1'); + expect(m.forward?.kind).toBe('single'); + expect(forwardsService.hydratePayload).toHaveBeenCalledTimes(1); + expect(forwardsService.hydratePayload).toHaveBeenCalledWith( + 'm-fwd', + 'u-1', + expect.objectContaining({ kind: 'single' }), + ); + }); + + it('skips hydration for non-forward messages', async () => { + setupTextMessageMocks(); + + const m = await service.getMessageWithDetails('m-text', 'u-1'); + expect(m.forward).toBeUndefined(); + expect(forwardsService.hydratePayload).not.toHaveBeenCalled(); + }); + + it('skips hydration when userId is undefined', async () => { + setupForwardMessageMocks({ + forward: { + kind: 'single', + count: 1, + sourceChannelId: 'c', + sourceChannelName: '', + }, + }); + + const m = await service.getMessageWithDetails('m-fwd'); + expect(m.forward).toBeUndefined(); + expect(forwardsService.hydratePayload).not.toHaveBeenCalled(); + }); + + it('hydrates each forward in a paginated page via hydrateForwardsBatch', async () => { + const fwdPayload = { + kind: 'single', + count: 1, + items: [], + sourceChannelId: 'c', + sourceChannelName: null, + truncated: false, + }; + forwardsService.hydratePayload.mockResolvedValue(fwdPayload as never); + + // Use mergeProperties spy to avoid DB complexity and focus on hydration logic. + const fwdMsg1 = makeMessageResponse({ + id: 'fwd-1', + type: 'forward' as never, + metadata: { + forward: { + kind: 'single', + count: 1, + sourceChannelId: 'c', + sourceChannelName: '', + }, + }, + }); + const fwdMsg2 = makeMessageResponse({ + id: 'fwd-2', + type: 'forward' as never, + metadata: { + forward: { + kind: 'single', + count: 1, + sourceChannelId: 'c', + sourceChannelName: '', + }, + }, + }); + const textMsg = makeMessageResponse({ id: 'text-1', type: 'text' }); + + // Spy on hydrateForwardsBatch so we can verify it's called properly. + const hydrateForwardsBatchSpy = jest + .spyOn(service as any, 'hydrateForwardsBatch') + .mockImplementation(async (messages: MessageResponse[]) => { + for (const m of messages) { + if (m.type === 'forward') { + (m as any).forward = fwdPayload; + } + } + }); + + // Spy on mergeProperties and getMessagesWithDetailsBatch to isolate + jest + .spyOn(service, 'mergeProperties') + .mockImplementation(async (msgs) => msgs); + + jest + .spyOn(service as any, 'getMessagesWithDetailsBatch') + .mockResolvedValue( + new Map([ + ['fwd-1', fwdMsg1], + ['fwd-2', fwdMsg2], + ['text-1', textMsg], + ]), + ); + + // Mock DB rows query (before mode): needs .orderBy().limit() chain + const limitMock = jest + .fn() + .mockResolvedValue([ + makeMessageRow({ id: 'fwd-1' }), + makeMessageRow({ id: 'fwd-2' }), + makeMessageRow({ id: 'text-1' }), + ]); + const orderByMock = jest.fn().mockReturnValue({ limit: limitMock }); + const whereMock = jest + .fn() + .mockReturnValue({ orderBy: orderByMock }); + const fromMock = jest.fn().mockReturnValue({ where: whereMock }); + db.select.mockReturnValueOnce({ from: fromMock }); + + const page = await service.getChannelMessagesPaginated( + 'ch-1', + 50, + {}, + 'u-1', + ); + + expect(hydrateForwardsBatchSpy).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ type: 'forward' }), + expect.objectContaining({ type: 'forward' }), + expect.objectContaining({ type: 'text' }), + ]), + 'u-1', + ); + const fwdCount = page.messages.filter((m) => m.type === 'forward').length; + expect(fwdCount).toBe(2); + // hydratePayload is called by hydrateForwardsBatch (which is the real impl + // in production — here we mocked it, so verify the spy was called) + expect(hydrateForwardsBatchSpy).toHaveBeenCalledTimes(1); + }); + }); + + // ---- PATCH guard for forward type ---- + + describe('update', () => { + it('rejects PATCH on forward-type message with forward.editDisabled', async () => { + db.chains.selectLimit.mockResolvedValueOnce([ + makeMessageRow({ type: 'forward' }), + ]); + + await expect( + service.update('m-fwd', 'u-1', { content: 'x' } as never), + ).rejects.toThrow('forward.editDisabled'); + }); + }); }); diff --git a/apps/server/apps/gateway/src/im/messages/messages.service.ts b/apps/server/apps/gateway/src/im/messages/messages.service.ts index 3211cc9e..4bf62643 100644 --- a/apps/server/apps/gateway/src/im/messages/messages.service.ts +++ b/apps/server/apps/gateway/src/im/messages/messages.service.ts @@ -4,6 +4,7 @@ import { Optional, NotFoundException, ForbiddenException, + BadRequestException, Logger, } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; @@ -38,8 +39,13 @@ import { MessagePropertiesService } from '../properties/message-properties.servi import { GatewayMQService } from '@team9/rabbitmq'; import { type PostBroadcastTask } from '@team9/shared'; import { ImWorkerGrpcClientService } from '../services/im-worker-grpc-client.service.js'; -import { WEBSOCKET_GATEWAY } from '../../shared/constants/injection-tokens.js'; +import { + WEBSOCKET_GATEWAY, + FORWARDS_SERVICE, +} from '../../shared/constants/injection-tokens.js'; import type { WebsocketGateway } from '../websocket/websocket.gateway.js'; +import type { ForwardsService } from './forwards/forwards.service.js'; +import type { ForwardMetadata, ForwardPayload } from './forwards/types.js'; export interface MessageSender { id: string; @@ -81,7 +87,14 @@ export interface MessageResponse { // Lexical serialized EditorState (null for legacy/bot/system messages that // render via the sanitized HTML/Markdown fallback path). contentAst: Record | null; - type: 'text' | 'file' | 'image' | 'system' | 'tracking' | 'long_text'; + type: + | 'text' + | 'file' + | 'image' + | 'system' + | 'tracking' + | 'long_text' + | 'forward'; isTruncated?: boolean; fullContentLength?: number; isPinned: boolean; @@ -97,6 +110,7 @@ export interface MessageResponse { lastReplyAt: Date | null; metadata?: Record | null; properties?: Record; + forward?: ForwardPayload; } export interface PaginatedMessagesResponse { @@ -146,6 +160,15 @@ export class MessagesService { return this.moduleRef.get(WEBSOCKET_GATEWAY, { strict: false }); } + /** Lazily resolve ForwardsService to avoid ESM circular dependency at import time */ + private get forwardsService(): ForwardsService | undefined { + try { + return this.moduleRef.get(FORWARDS_SERVICE, { strict: false }); + } catch { + return undefined; + } + } + private mapMessageSender(row: { id: string; username: string; @@ -207,7 +230,10 @@ export class MessagesService { return sendersMap; } - async getMessageWithDetails(messageId: string): Promise { + async getMessageWithDetails( + messageId: string, + userId?: string, + ): Promise { const [message] = await this.db .select() .from(schema.messages) @@ -294,7 +320,7 @@ export class MessagesService { } } - return { + const response: MessageResponse = { id: message.id, clientMsgId: message.clientMsgId ?? null, channelId: message.channelId, @@ -317,6 +343,19 @@ export class MessagesService { lastReplyAt, metadata: message.metadata, }; + + if (response.type === 'forward' && userId && this.forwardsService) { + const meta = (response.metadata ?? {}) as { forward?: ForwardMetadata }; + if (meta.forward) { + response.forward = await this.forwardsService.hydratePayload( + response.id, + userId, + meta.forward, + ); + } + } + + return response; } /** @@ -493,6 +532,31 @@ export class MessagesService { return result; } + /** + * Hydrate forward payloads for all forward-type messages in a batch. + * Mutates the passed array in place for efficiency. + */ + private async hydrateForwardsBatch( + messages: MessageResponse[], + userId: string | undefined, + ): Promise { + if (!userId || !this.forwardsService) return; + const fwd = messages.filter((m) => m.type === 'forward'); + if (fwd.length === 0) return; + await Promise.all( + fwd.map(async (m) => { + const meta = (m.metadata ?? {}) as { forward?: ForwardMetadata }; + if (meta.forward) { + m.forward = await this.forwardsService!.hydratePayload( + m.id, + userId, + meta.forward, + ); + } + }), + ); + } + /** * Batch-load properties for messages and merge into responses. * Only loads properties with showInChatPolicy !== 'hide'. @@ -521,6 +585,7 @@ export class MessagesService { channelId: string, limit = 50, before?: string, + userId?: string, ): Promise { let query = this.db .select() @@ -567,6 +632,7 @@ export class MessagesService { const messages = messageList .map((m) => detailsMap.get(m.id)) .filter((m): m is MessageResponse => !!m); + await this.hydrateForwardsBatch(messages, userId); return this.mergeProperties(messages); } @@ -582,6 +648,7 @@ export class MessagesService { channelId: string, limit: number, cursors: { before?: string; after?: string; around?: string }, + userId?: string, ): Promise { const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -610,7 +677,7 @@ export class MessagesService { const anchorTime = await resolveTimestamp(cursors.around); if (!anchorTime) { // Anchor message not found, fall back to latest - return this.getChannelMessagesPaginated(channelId, limit, {}); + return this.getChannelMessagesPaginated(channelId, limit, {}, userId); } const halfBefore = Math.floor(limit / 2); @@ -644,11 +711,11 @@ export class MessagesService { ); const detailsMap = await this.getMessagesWithDetailsBatch(combined); - const messages = await this.mergeProperties( - combined - .map((m) => detailsMap.get(m.id)) - .filter((m): m is MessageResponse => !!m), - ); + const rawMessages = combined + .map((m) => detailsMap.get(m.id)) + .filter((m): m is MessageResponse => !!m); + await this.hydrateForwardsBatch(rawMessages, userId); + const messages = await this.mergeProperties(rawMessages); return { messages, hasOlder, hasNewer }; } @@ -673,11 +740,11 @@ export class MessagesService { trimmed.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); const detailsMap = await this.getMessagesWithDetailsBatch(trimmed); - const messages = await this.mergeProperties( - trimmed - .map((m) => detailsMap.get(m.id)) - .filter((m): m is MessageResponse => !!m), - ); + const rawMessages = trimmed + .map((m) => detailsMap.get(m.id)) + .filter((m): m is MessageResponse => !!m); + await this.hydrateForwardsBatch(rawMessages, userId); + const messages = await this.mergeProperties(rawMessages); return { messages, hasOlder: true, hasNewer }; } @@ -708,11 +775,11 @@ export class MessagesService { const trimmed = rows.slice(0, limit); const detailsMap = await this.getMessagesWithDetailsBatch(trimmed); - const messages = await this.mergeProperties( - trimmed - .map((m) => detailsMap.get(m.id)) - .filter((m): m is MessageResponse => !!m), - ); + const rawMessages = trimmed + .map((m) => detailsMap.get(m.id)) + .filter((m): m is MessageResponse => !!m); + await this.hydrateForwardsBatch(rawMessages, userId); + const messages = await this.mergeProperties(rawMessages); return { messages, hasOlder, hasNewer: hasCursor }; } @@ -731,9 +798,10 @@ export class MessagesService { rootMessageId: string, limit = 20, cursor?: string, + userId?: string, ): Promise { // Get root message - const rootMessage = await this.getMessageWithDetails(rootMessageId); + const rootMessage = await this.getMessageWithDetails(rootMessageId, userId); // Build query conditions const conditions = [ @@ -826,6 +894,14 @@ export class MessagesService { // Total first-level reply count const totalReplyCount = firstLevelReplies.length; + // Hydrate forwards in all reply messages (first-level + sub-replies) + const allReplyMessages: MessageResponse[] = []; + for (const r of replies) { + allReplyMessages.push(r); + allReplyMessages.push(...r.subReplies); + } + await this.hydrateForwardsBatch(allReplyMessages, userId); + return { rootMessage, replies, @@ -848,6 +924,7 @@ export class MessagesService { parentId: string, limit = 20, cursor?: string, + userId?: string, ): Promise { // Build query conditions const conditions = [ @@ -883,9 +960,11 @@ export class MessagesService { // Use batch query instead of N individual queries const detailsMap = await this.getMessagesWithDetailsBatch(actualReplies); + const replyMessages = actualReplies.map((m) => detailsMap.get(m.id)!); + await this.hydrateForwardsBatch(replyMessages, userId); return { - replies: actualReplies.map((m) => detailsMap.get(m.id)!), + replies: replyMessages, hasMore, nextCursor, }; @@ -930,6 +1009,10 @@ export class MessagesService { throw new NotFoundException('Message not found'); } + if (message.type === 'forward') { + throw new BadRequestException('forward.editDisabled'); + } + if (message.senderId !== userId) { throw new ForbiddenException('Cannot edit message from another user'); } diff --git a/apps/server/apps/gateway/src/shared/constants/injection-tokens.ts b/apps/server/apps/gateway/src/shared/constants/injection-tokens.ts index 7b22c467..fdfbd015 100644 --- a/apps/server/apps/gateway/src/shared/constants/injection-tokens.ts +++ b/apps/server/apps/gateway/src/shared/constants/injection-tokens.ts @@ -1,2 +1,3 @@ // Injection tokens for circular dependencies export const WEBSOCKET_GATEWAY = Symbol('WEBSOCKET_GATEWAY'); +export const FORWARDS_SERVICE = Symbol('FORWARDS_SERVICE'); From 396872335f9f97f6f97476383e648aec0d1ea944 Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 2 May 2026 16:26:12 +0800 Subject: [PATCH 11/23] test(im): HTTP-integration spec for forward endpoints 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 --- .../apps/gateway/test/forward.e2e-spec.ts | 345 ++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 apps/server/apps/gateway/test/forward.e2e-spec.ts diff --git a/apps/server/apps/gateway/test/forward.e2e-spec.ts b/apps/server/apps/gateway/test/forward.e2e-spec.ts new file mode 100644 index 00000000..64caf152 --- /dev/null +++ b/apps/server/apps/gateway/test/forward.e2e-spec.ts @@ -0,0 +1,345 @@ +/** + * HTTP-level integration tests for the forward endpoints. + * + * Scoped to "controller + guards + pipes" integration, not full e2e. + * Persistence, Redis, RabbitMQ, and WebSocket broadcasting are NOT + * exercised here — those scenarios belong to environments with the + * necessary infra wired up (covered by Task 13's manual smoke step). + * + * What this spec pins: + * - Route URLs match the declared @Controller / @Post / @Get decorators. + * - AuthGuard runs before any handler logic. + * - ParseUUIDPipe rejects non-UUID path params with 400. + * - ValidationPipe enforces all DTO constraints (empty array, >100 ids, + * non-UUID sourceChannelId, non-UUID ids inside the array). + * - Successful calls delegate to ForwardsService with the expected shape. + */ + +import { + beforeEach, + afterEach, + describe, + it, + expect, + jest, +} from '@jest/globals'; +import { + type INestApplication, + ValidationPipe, + type ExecutionContext, + type CanActivate, + UnauthorizedException, + VersioningType, +} from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { AuthGuard } from '@team9/auth'; +import { ForwardsController } from '../src/im/messages/forwards/forwards.controller.js'; +import { ForwardsService } from '../src/im/messages/forwards/forwards.service.js'; + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +const TARGET_CHANNEL = '019cd29d-4852-748f-ad39-dbc28410914e'; +const SOURCE_CHANNEL = '019cd29d-5000-7000-a000-b00000000000'; +const SOURCE_MSG = '019cd29d-6000-7000-a000-c00000000000'; +const USER_ID = '019cd29d-7000-7000-a000-d00000000000'; + +// --------------------------------------------------------------------------- +// Test auth guard — mirrors the pattern used in ahand-integration.e2e-spec.ts +// --------------------------------------------------------------------------- + +class TestAuthGuard implements CanActivate { + static allow = true; + static sub = USER_ID; + + canActivate(ctx: ExecutionContext): boolean { + if (!TestAuthGuard.allow) { + throw new UnauthorizedException('test guard: denied'); + } + const req = ctx.switchToHttp().getRequest<{ user?: { sub: string } }>(); + req.user = { sub: TestAuthGuard.sub }; + return true; + } +} + +// --------------------------------------------------------------------------- +// Suite +// --------------------------------------------------------------------------- + +describe('Forward HTTP (integration)', () => { + let app: INestApplication; + let svc: { + forward: ReturnType; + getForwardItems: ReturnType; + }; + + beforeEach(async () => { + svc = { + forward: jest.fn(), + getForwardItems: jest.fn(), + }; + + TestAuthGuard.allow = true; + TestAuthGuard.sub = USER_ID; + + const moduleRef = await Test.createTestingModule({ + controllers: [ForwardsController], + providers: [{ provide: ForwardsService, useValue: svc }], + }) + .overrideGuard(AuthGuard) + .useClass(TestAuthGuard) + .compile(); + + app = moduleRef.createNestApplication(); + // Mirror production bootstrap from apps/server/apps/gateway/src/main.ts: + app.setGlobalPrefix('api'); + app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' }); + app.useGlobalPipes(new ValidationPipe({ whitelist: true })); + await app.init(); + }); + + afterEach(async () => { + await app.close(); + }); + + // ------------------------------------------------------------------------- + // POST /api/v1/im/channels/:targetChannelId/forward + // ------------------------------------------------------------------------- + + describe('POST /api/v1/im/channels/:targetChannelId/forward', () => { + it('delegates to ForwardsService.forward on happy path and returns 201', async () => { + const mockResponse = { + id: SOURCE_MSG, + type: 'forward', + channelId: TARGET_CHANNEL, + senderId: USER_ID, + content: 'forwarded', + createdAt: '2026-01-01T00:00:00.000Z', + }; + svc.forward.mockResolvedValueOnce(mockResponse); + + const httpServer = app.getHttpServer() as Parameters[0]; + const res = await request(httpServer) + .post(`/api/v1/im/channels/${TARGET_CHANNEL}/forward`) + .send({ + sourceChannelId: SOURCE_CHANNEL, + sourceMessageIds: [SOURCE_MSG], + clientMsgId: 'cid-001', + }); + + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ id: SOURCE_MSG, type: 'forward' }); + expect(svc.forward).toHaveBeenCalledWith({ + targetChannelId: TARGET_CHANNEL, + sourceChannelId: SOURCE_CHANNEL, + sourceMessageIds: [SOURCE_MSG], + clientMsgId: 'cid-001', + userId: USER_ID, + }); + }); + + it('returns 201 without optional clientMsgId', async () => { + svc.forward.mockResolvedValueOnce({ + id: SOURCE_MSG, + type: 'forward', + }); + + const httpServer = app.getHttpServer() as Parameters[0]; + const res = await request(httpServer) + .post(`/api/v1/im/channels/${TARGET_CHANNEL}/forward`) + .send({ + sourceChannelId: SOURCE_CHANNEL, + sourceMessageIds: [SOURCE_MSG], + }); + + expect(res.status).toBe(201); + expect(svc.forward).toHaveBeenCalledWith( + expect.objectContaining({ + targetChannelId: TARGET_CHANNEL, + userId: USER_ID, + clientMsgId: undefined, + }), + ); + }); + + it('returns 400 for non-UUID targetChannelId (ParseUUIDPipe)', async () => { + const httpServer = app.getHttpServer() as Parameters[0]; + const res = await request(httpServer) + .post('/api/v1/im/channels/not-a-uuid/forward') + .send({ + sourceChannelId: SOURCE_CHANNEL, + sourceMessageIds: [SOURCE_MSG], + }); + + expect(res.status).toBe(400); + expect(svc.forward).not.toHaveBeenCalled(); + }); + + it('returns 400 with forward.empty message for empty sourceMessageIds', async () => { + const httpServer = app.getHttpServer() as Parameters[0]; + const res = await request(httpServer) + .post(`/api/v1/im/channels/${TARGET_CHANNEL}/forward`) + .send({ + sourceChannelId: SOURCE_CHANNEL, + sourceMessageIds: [], + }); + + expect(res.status).toBe(400); + expect(JSON.stringify(res.body)).toContain('forward.empty'); + expect(svc.forward).not.toHaveBeenCalled(); + }); + + it('returns 400 with forward.tooManySelected when sourceMessageIds.length > 100', async () => { + const ids = Array.from({ length: 101 }, () => SOURCE_MSG); + const httpServer = app.getHttpServer() as Parameters[0]; + const res = await request(httpServer) + .post(`/api/v1/im/channels/${TARGET_CHANNEL}/forward`) + .send({ + sourceChannelId: SOURCE_CHANNEL, + sourceMessageIds: ids, + }); + + expect(res.status).toBe(400); + expect(JSON.stringify(res.body)).toContain('forward.tooManySelected'); + expect(svc.forward).not.toHaveBeenCalled(); + }); + + it('returns 400 for non-UUID sourceChannelId in body', async () => { + const httpServer = app.getHttpServer() as Parameters[0]; + const res = await request(httpServer) + .post(`/api/v1/im/channels/${TARGET_CHANNEL}/forward`) + .send({ + sourceChannelId: 'not-a-uuid', + sourceMessageIds: [SOURCE_MSG], + }); + + expect(res.status).toBe(400); + expect(svc.forward).not.toHaveBeenCalled(); + }); + + it('returns 400 for non-UUID id inside sourceMessageIds', async () => { + const httpServer = app.getHttpServer() as Parameters[0]; + const res = await request(httpServer) + .post(`/api/v1/im/channels/${TARGET_CHANNEL}/forward`) + .send({ + sourceChannelId: SOURCE_CHANNEL, + sourceMessageIds: ['nope-not-a-uuid'], + }); + + expect(res.status).toBe(400); + expect(svc.forward).not.toHaveBeenCalled(); + }); + + it('returns 400 when sourceChannelId is missing', async () => { + const httpServer = app.getHttpServer() as Parameters[0]; + const res = await request(httpServer) + .post(`/api/v1/im/channels/${TARGET_CHANNEL}/forward`) + .send({ + sourceMessageIds: [SOURCE_MSG], + }); + + expect(res.status).toBe(400); + expect(svc.forward).not.toHaveBeenCalled(); + }); + + it('returns 400 when sourceMessageIds is missing', async () => { + const httpServer = app.getHttpServer() as Parameters[0]; + const res = await request(httpServer) + .post(`/api/v1/im/channels/${TARGET_CHANNEL}/forward`) + .send({ + sourceChannelId: SOURCE_CHANNEL, + }); + + expect(res.status).toBe(400); + expect(svc.forward).not.toHaveBeenCalled(); + }); + + it('returns 401 when auth guard denies', async () => { + TestAuthGuard.allow = false; + + const httpServer = app.getHttpServer() as Parameters[0]; + const res = await request(httpServer) + .post(`/api/v1/im/channels/${TARGET_CHANNEL}/forward`) + .send({ + sourceChannelId: SOURCE_CHANNEL, + sourceMessageIds: [SOURCE_MSG], + }); + + expect(res.status).toBe(401); + expect(svc.forward).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // GET /api/v1/im/messages/:id/forward-items + // ------------------------------------------------------------------------- + + describe('GET /api/v1/im/messages/:id/forward-items', () => { + it('delegates to ForwardsService.getForwardItems and returns 200 with items', async () => { + const mockItems = [ + { + position: 0, + sourceMessageId: SOURCE_MSG, + sourceChannelId: SOURCE_CHANNEL, + sourceChannelName: 'general', + sourceWorkspaceId: null, + sourceSender: null, + sourceCreatedAt: '2026-01-01T00:00:00.000Z', + sourceSeqId: null, + sourceType: 'text', + contentSnapshot: 'hello', + contentAstSnapshot: null, + attachmentsSnapshot: [], + canJumpToOriginal: true, + truncated: false, + }, + ]; + svc.getForwardItems.mockResolvedValueOnce(mockItems); + + const httpServer = app.getHttpServer() as Parameters[0]; + const res = await request(httpServer).get( + `/api/v1/im/messages/${SOURCE_MSG}/forward-items`, + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual(mockItems); + expect(svc.getForwardItems).toHaveBeenCalledWith(SOURCE_MSG, USER_ID); + }); + + it('returns 200 with empty array when no items', async () => { + svc.getForwardItems.mockResolvedValueOnce([]); + + const httpServer = app.getHttpServer() as Parameters[0]; + const res = await request(httpServer).get( + `/api/v1/im/messages/${SOURCE_MSG}/forward-items`, + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); + + it('returns 400 for non-UUID message id (ParseUUIDPipe)', async () => { + const httpServer = app.getHttpServer() as Parameters[0]; + const res = await request(httpServer).get( + '/api/v1/im/messages/not-a-uuid/forward-items', + ); + + expect(res.status).toBe(400); + expect(svc.getForwardItems).not.toHaveBeenCalled(); + }); + + it('returns 401 when auth guard denies on GET', async () => { + TestAuthGuard.allow = false; + + const httpServer = app.getHttpServer() as Parameters[0]; + const res = await request(httpServer).get( + `/api/v1/im/messages/${SOURCE_MSG}/forward-items`, + ); + + expect(res.status).toBe(401); + expect(svc.getForwardItems).not.toHaveBeenCalled(); + }); + }); +}); From a4e4df48018197ad4ead9b99c9f35c436c0c4e84 Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 2 May 2026 16:31:00 +0800 Subject: [PATCH 12/23] feat(client): add forward types, API methods, selection store - 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 --- apps/client/src/services/api/forward.ts | 33 +++++ apps/client/src/services/api/index.ts | 2 + .../useForwardSelectionStore.test.ts | 113 ++++++++++++++++++ .../src/stores/useForwardSelectionStore.ts | 58 +++++++++ apps/client/src/types/im.ts | 47 +++++++- 5 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 apps/client/src/services/api/forward.ts create mode 100644 apps/client/src/stores/__tests__/useForwardSelectionStore.test.ts create mode 100644 apps/client/src/stores/useForwardSelectionStore.ts diff --git a/apps/client/src/services/api/forward.ts b/apps/client/src/services/api/forward.ts new file mode 100644 index 00000000..91c9dbae --- /dev/null +++ b/apps/client/src/services/api/forward.ts @@ -0,0 +1,33 @@ +import http from "../http"; +import type { Message, ForwardItem } from "@/types/im"; +import { normalizeMessage } from "./normalize-reactions"; + +export const forwardApi = { + // Forward messages to a target channel + create: async (input: { + targetChannelId: string; + sourceChannelId: string; + sourceMessageIds: string[]; + clientMsgId?: string; + }): Promise => { + const response = await http.post( + `/v1/im/channels/${input.targetChannelId}/forward`, + { + sourceChannelId: input.sourceChannelId, + sourceMessageIds: input.sourceMessageIds, + clientMsgId: input.clientMsgId, + }, + ); + return normalizeMessage(response.data); + }, + + // Get forward items for a forwarded message + getItems: async (messageId: string): Promise => { + const response = await http.get( + `/v1/im/messages/${messageId}/forward-items`, + ); + return response.data; + }, +}; + +export default forwardApi; diff --git a/apps/client/src/services/api/index.ts b/apps/client/src/services/api/index.ts index 9be809a4..b3ee5fd4 100644 --- a/apps/client/src/services/api/index.ts +++ b/apps/client/src/services/api/index.ts @@ -245,6 +245,7 @@ import * as notificationPreferencesApi from "./notification-preferences"; import propertiesApi from "./properties"; import { viewsApi, tabsApi } from "./views"; import wikisApi from "./wikis"; +import forwardApi from "./forward"; export const api = { auth: authApi, @@ -265,6 +266,7 @@ export const api = { views: viewsApi, tabs: tabsApi, wikis: wikisApi, + forward: forwardApi, }; export default api; diff --git a/apps/client/src/stores/__tests__/useForwardSelectionStore.test.ts b/apps/client/src/stores/__tests__/useForwardSelectionStore.test.ts new file mode 100644 index 00000000..7b5f045a --- /dev/null +++ b/apps/client/src/stores/__tests__/useForwardSelectionStore.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + useForwardSelectionStore, + FORWARD_SELECTION_MAX, +} from "../useForwardSelectionStore"; + +beforeEach(() => { + useForwardSelectionStore.getState().exit(); +}); + +describe("useForwardSelectionStore", () => { + it("enters mode for a channel", () => { + useForwardSelectionStore.getState().enter("ch-1"); + const s = useForwardSelectionStore.getState(); + expect(s.active).toBe(true); + expect(s.channelId).toBe("ch-1"); + expect(s.selectedIds.size).toBe(0); + }); + + it("exit resets state", () => { + useForwardSelectionStore.getState().enter("ch-1"); + useForwardSelectionStore.getState().toggle("m-1"); + useForwardSelectionStore.getState().exit(); + const s = useForwardSelectionStore.getState(); + expect(s.active).toBe(false); + expect(s.channelId).toBe(null); + expect(s.selectedIds.size).toBe(0); + }); + + it("toggle adds and removes ids", () => { + useForwardSelectionStore.getState().enter("ch-1"); + expect(useForwardSelectionStore.getState().toggle("m-1")).toBe(true); + expect(useForwardSelectionStore.getState().isSelected("m-1")).toBe(true); + expect(useForwardSelectionStore.getState().toggle("m-1")).toBe(true); + expect(useForwardSelectionStore.getState().isSelected("m-1")).toBe(false); + }); + + it("toggle returns false when inactive", () => { + expect(useForwardSelectionStore.getState().toggle("m-1")).toBe(false); + }); + + it("toggle enforces cap", () => { + useForwardSelectionStore.getState().enter("ch-1"); + for (let i = 0; i < FORWARD_SELECTION_MAX; i += 1) { + useForwardSelectionStore.getState().toggle(`m-${i}`); + } + expect(useForwardSelectionStore.getState().toggle("overflow")).toBe(false); + expect(useForwardSelectionStore.getState().selectedIds.size).toBe( + FORWARD_SELECTION_MAX, + ); + }); + + it("addRange respects cap and returns added count", () => { + useForwardSelectionStore.getState().enter("ch-1"); + const ids = Array.from({ length: 150 }, (_, i) => `m-${i}`); + const added = useForwardSelectionStore.getState().addRange(ids); + expect(added).toBe(FORWARD_SELECTION_MAX); + expect(useForwardSelectionStore.getState().selectedIds.size).toBe( + FORWARD_SELECTION_MAX, + ); + }); + + it("addRange returns 0 when inactive", () => { + expect(useForwardSelectionStore.getState().addRange(["m-1"])).toBe(0); + }); + + it("addRange skips already-selected ids", () => { + useForwardSelectionStore.getState().enter("ch-1"); + useForwardSelectionStore.getState().toggle("m-1"); + const added = useForwardSelectionStore.getState().addRange(["m-1", "m-2"]); + expect(added).toBe(1); + }); + + it("clear empties selection without exiting", () => { + useForwardSelectionStore.getState().enter("ch-1"); + useForwardSelectionStore.getState().toggle("m-1"); + useForwardSelectionStore.getState().clear(); + const s = useForwardSelectionStore.getState(); + expect(s.selectedIds.size).toBe(0); + expect(s.active).toBe(true); + }); + + it("entering a different channel clears selection", () => { + useForwardSelectionStore.getState().enter("ch-1"); + useForwardSelectionStore.getState().toggle("m-1"); + useForwardSelectionStore.getState().enter("ch-2"); + const s = useForwardSelectionStore.getState(); + expect(s.channelId).toBe("ch-2"); + expect(s.selectedIds.size).toBe(0); + }); + + it("addRange does not mutate state when nothing new is added", () => { + useForwardSelectionStore.getState().enter("ch-1"); + useForwardSelectionStore.getState().toggle("m-1"); + const sizeBefore = useForwardSelectionStore.getState().selectedIds.size; + const added = useForwardSelectionStore.getState().addRange(["m-1"]); + expect(added).toBe(0); + expect(useForwardSelectionStore.getState().selectedIds.size).toBe( + sizeBefore, + ); + }); + + it("isSelected returns false for unselected message", () => { + useForwardSelectionStore.getState().enter("ch-1"); + expect( + useForwardSelectionStore.getState().isSelected("m-not-selected"), + ).toBe(false); + }); + + it("FORWARD_SELECTION_MAX is 100", () => { + expect(FORWARD_SELECTION_MAX).toBe(100); + }); +}); diff --git a/apps/client/src/stores/useForwardSelectionStore.ts b/apps/client/src/stores/useForwardSelectionStore.ts new file mode 100644 index 00000000..96f3490c --- /dev/null +++ b/apps/client/src/stores/useForwardSelectionStore.ts @@ -0,0 +1,58 @@ +import { create } from "zustand"; + +const MAX_SELECTED = 100; +export const FORWARD_SELECTION_MAX = MAX_SELECTED; + +interface ForwardSelectionState { + active: boolean; + channelId: string | null; + selectedIds: Set; + enter: (channelId: string) => void; + exit: () => void; + toggle: (messageId: string) => boolean; + addRange: (messageIds: string[]) => number; + clear: () => void; + isSelected: (messageId: string) => boolean; +} + +export const useForwardSelectionStore = create( + (set, get) => ({ + active: false, + channelId: null, + selectedIds: new Set(), + enter: (channelId) => + set({ active: true, channelId, selectedIds: new Set() }), + exit: () => set({ active: false, channelId: null, selectedIds: new Set() }), + toggle: (messageId) => { + const state = get(); + if (!state.active) return false; + const next = new Set(state.selectedIds); + if (next.has(messageId)) { + next.delete(messageId); + set({ selectedIds: next }); + return true; + } + if (next.size >= MAX_SELECTED) return false; + next.add(messageId); + set({ selectedIds: next }); + return true; + }, + addRange: (messageIds) => { + const state = get(); + if (!state.active) return 0; + const next = new Set(state.selectedIds); + let added = 0; + for (const id of messageIds) { + if (next.size >= MAX_SELECTED) break; + if (!next.has(id)) { + next.add(id); + added += 1; + } + } + if (added > 0) set({ selectedIds: next }); + return added; + }, + clear: () => set({ selectedIds: new Set() }), + isSelected: (messageId) => get().selectedIds.has(messageId), + }), +); diff --git a/apps/client/src/types/im.ts b/apps/client/src/types/im.ts index 71711619..dda4992c 100644 --- a/apps/client/src/types/im.ts +++ b/apps/client/src/types/im.ts @@ -15,7 +15,8 @@ export type MessageType = | "image" | "system" | "tracking" - | "long_text"; + | "long_text" + | "forward"; export type MemberRole = "owner" | "admin" | "member"; export type UserStatus = "online" | "offline" | "away" | "busy"; export type MessageSendStatus = "sending" | "sent" | "failed"; @@ -74,6 +75,49 @@ export interface AgentEventMetadata { startedAt?: string; } +export interface ForwardAttachmentSnapshot { + originalAttachmentId: string; + fileName: string; + fileUrl: string; + fileKey: string | null; + fileSize: number; + mimeType: string; + thumbnailUrl: string | null; + width: number | null; + height: number | null; +} + +export interface ForwardItem { + position: number; + sourceMessageId: string | null; + sourceChannelId: string; + sourceChannelName: string | null; + sourceWorkspaceId: string | null; + sourceSender: { + id: string; + username: string; + displayName: string | null; + avatarUrl: string | null; + } | null; + sourceCreatedAt: string; + sourceSeqId: string | null; + sourceType: "text" | "long_text" | "file" | "image" | "forward"; + contentSnapshot: string | null; + contentAstSnapshot: Record | null; + attachmentsSnapshot: ForwardAttachmentSnapshot[]; + canJumpToOriginal: boolean; + truncated: boolean; +} + +export interface ForwardPayload { + kind: "single" | "bundle"; + count: number; + sourceChannelId: string; + sourceChannelName: string | null; + truncated: boolean; + items: ForwardItem[]; +} + export interface ChannelSnapshot { totalMessageCount: number; latestMessages: Array<{ @@ -207,6 +251,7 @@ export interface Message { attachments?: MessageAttachment[]; reactions?: MessageReaction[]; properties?: Record; + forward?: ForwardPayload; isTruncated?: boolean; fullContentLength?: number; replyCount?: number; From 7a6afb687fe6a16680cd07494b7178d868129c0f Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 2 May 2026 16:43:15 +0800 Subject: [PATCH 13/23] feat(client): add ForwardDialog with channel picker and preview 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 --- .../channel/forward/ForwardChannelList.tsx | 57 ++ .../channel/forward/ForwardDialog.tsx | 118 ++++ .../channel/forward/ForwardPreview.tsx | 66 ++ .../__tests__/ForwardChannelList.test.tsx | 259 ++++++++ .../forward/__tests__/ForwardDialog.test.tsx | 599 ++++++++++++++++++ .../forward/__tests__/ForwardPreview.test.tsx | 233 +++++++ apps/client/src/i18n/locales/en/channel.json | 1 + .../src/i18n/locales/zh-CN/channel.json | 1 + 8 files changed, 1334 insertions(+) create mode 100644 apps/client/src/components/channel/forward/ForwardChannelList.tsx create mode 100644 apps/client/src/components/channel/forward/ForwardDialog.tsx create mode 100644 apps/client/src/components/channel/forward/ForwardPreview.tsx create mode 100644 apps/client/src/components/channel/forward/__tests__/ForwardChannelList.test.tsx create mode 100644 apps/client/src/components/channel/forward/__tests__/ForwardDialog.test.tsx create mode 100644 apps/client/src/components/channel/forward/__tests__/ForwardPreview.test.tsx diff --git a/apps/client/src/components/channel/forward/ForwardChannelList.tsx b/apps/client/src/components/channel/forward/ForwardChannelList.tsx new file mode 100644 index 00000000..a12c35f8 --- /dev/null +++ b/apps/client/src/components/channel/forward/ForwardChannelList.tsx @@ -0,0 +1,57 @@ +import { useState, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useChannels } from "@/hooks/useChannels"; +import { Input } from "@/components/ui/input"; + +interface Props { + excludeChannelId?: string; + selectedChannelId: string | null; + onSelect: (channelId: string) => void; +} + +export function ForwardChannelList({ + excludeChannelId, + selectedChannelId, + onSelect, +}: Props) { + const { t } = useTranslation("channel"); + const { data: channels = [] } = useChannels(); + const [query, setQuery] = useState(""); + + const filtered = useMemo(() => { + return channels.filter((c) => { + if (c.id === excludeChannelId) return false; + if (c.isArchived) return false; + if (c.isActivated === false) return false; + if (!query) return true; + return c.name.toLowerCase().includes(query.toLowerCase()); + }); + }, [channels, query, excludeChannelId]); + + return ( +
+ setQuery(e.target.value)} + placeholder={t("forward.dialog.searchPlaceholder")} + aria-label={t("forward.dialog.searchPlaceholder")} + /> +
    + {filtered.map((c) => ( +
  • onSelect(c.id)} + className={`cursor-pointer px-3 py-2 text-sm hover:bg-accent ${ + selectedChannelId === c.id ? "bg-accent" : "" + }`} + > + #{c.name} +
  • + ))} +
+
+ ); +} diff --git a/apps/client/src/components/channel/forward/ForwardDialog.tsx b/apps/client/src/components/channel/forward/ForwardDialog.tsx new file mode 100644 index 00000000..15dbc9dc --- /dev/null +++ b/apps/client/src/components/channel/forward/ForwardDialog.tsx @@ -0,0 +1,118 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { api } from "@/services/api"; +import { ForwardChannelList } from "./ForwardChannelList"; +import { ForwardPreview } from "./ForwardPreview"; +import type { Message } from "@/types/im"; + +const ERROR_TO_KEY: Record = { + "forward.tooManySelected": "forward.tooManySelected", + "forward.mixedChannels": "forward.error.mixedChannels", + "forward.noWriteAccess": "forward.error.noWriteAccess", + "forward.noSourceAccess": "forward.error.noSourceAccess", + "forward.notAllowed": "forward.error.notAllowed", + "forward.notFound": "forward.error.notFound", + "forward.empty": "forward.error.empty", +}; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + sourceChannelId: string; + sourceMessages: Message[]; + onSuccess?: () => void; +} + +export function ForwardDialog({ + open, + onOpenChange, + sourceChannelId, + sourceMessages, + onSuccess, +}: Props) { + const { t } = useTranslation("channel"); + const queryClient = useQueryClient(); + const [targetChannelId, setTargetChannelId] = useState(null); + + const mutation = useMutation({ + mutationFn: (channelId: string) => { + return api.forward.create({ + targetChannelId: channelId, + sourceChannelId, + sourceMessageIds: sourceMessages.map((m) => m.id), + }); + }, + onSuccess: (_data, channelId) => { + toast(t("forward.success")); + queryClient.invalidateQueries({ + queryKey: ["channelMessages", channelId], + }); + setTargetChannelId(null); + onOpenChange(false); + onSuccess?.(); + }, + onError: (err: unknown) => { + const code = extractErrorCode(err); + const key = ERROR_TO_KEY[code] ?? "forward.error.notAllowed"; + toast.error(t(key)); + }, + }); + + const title = + sourceMessages.length === 1 + ? t("forward.dialog.titleSingle") + : t("forward.dialog.titleBundle", { count: sourceMessages.length }); + + return ( + + + + {title} + +
+ + +
+ + + + +
+
+ ); +} + +function extractErrorCode(err: unknown): string { + if (err && typeof err === "object") { + // The custom HttpClient surfaces server error body via error.response.data + // NestJS returns { statusCode, message, error } for BadRequestException + const e = err as { + response?: { data?: { message?: string } }; + message?: string; + }; + if (e.response?.data?.message) return e.response.data.message; + if (typeof e.message === "string") return e.message; + } + return ""; +} diff --git a/apps/client/src/components/channel/forward/ForwardPreview.tsx b/apps/client/src/components/channel/forward/ForwardPreview.tsx new file mode 100644 index 00000000..c2c50fbd --- /dev/null +++ b/apps/client/src/components/channel/forward/ForwardPreview.tsx @@ -0,0 +1,66 @@ +import { useTranslation } from "react-i18next"; +import type { Message } from "@/types/im"; +import { UserAvatar } from "@/components/ui/user-avatar"; + +interface Props { + messages: Message[]; +} + +export function ForwardPreview({ messages }: Props) { + const { t } = useTranslation("channel"); + + if (messages.length === 1) { + const m = messages[0]; + return ( +
+
+ + {m.sender?.displayName ?? m.sender?.username ?? ""} +
+
+ {m.content} +
+
+ ); + } + + return ( +
+
+ {t("forward.bundle.title", { count: messages.length })} +
+
    + {messages.slice(0, 3).map((m) => ( +
  • + + + {m.sender?.displayName ?? m.sender?.username ?? ""} + + + {m.content?.slice(0, 80) ?? ""} + +
  • + ))} + {messages.length > 3 && ( +
  • + …{t("forward.bundle.viewAll")} +
  • + )} +
+
+ ); +} diff --git a/apps/client/src/components/channel/forward/__tests__/ForwardChannelList.test.tsx b/apps/client/src/components/channel/forward/__tests__/ForwardChannelList.test.tsx new file mode 100644 index 00000000..2d9d7fde --- /dev/null +++ b/apps/client/src/components/channel/forward/__tests__/ForwardChannelList.test.tsx @@ -0,0 +1,259 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; + +// ── Mocks ────────────────────────────────────────────────────────────────── + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (k: string) => k, + }), +})); + +const mockUseChannels = vi.hoisted(() => vi.fn()); +vi.mock("@/hooks/useChannels", () => ({ + useChannels: mockUseChannels, +})); + +// Mock Input to avoid deep UI component tree +vi.mock("@/components/ui/input", () => ({ + Input: ({ + value, + onChange, + placeholder, + "aria-label": ariaLabel, + type, + }: { + value: string; + onChange: (e: React.ChangeEvent) => void; + placeholder?: string; + "aria-label"?: string; + type?: string; + }) => ( + + ), +})); + +import { ForwardChannelList } from "../ForwardChannelList"; + +const makeChannel = ( + id: string, + name: string, + overrides: { + isArchived?: boolean; + isActivated?: boolean; + } = {}, +) => ({ + id, + tenantId: "t1", + name, + type: "public" as const, + createdBy: "u1", + order: 0, + isArchived: false, + isActivated: true, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, +}); + +beforeEach(() => { + mockUseChannels.mockReturnValue({ data: [] }); +}); + +describe("ForwardChannelList", () => { + describe("happy path — renders channels", () => { + it("renders all eligible channels", () => { + mockUseChannels.mockReturnValue({ + data: [makeChannel("ch1", "general"), makeChannel("ch2", "random")], + }); + + render( + , + ); + + expect(screen.getByText("#general")).toBeInTheDocument(); + expect(screen.getByText("#random")).toBeInTheDocument(); + }); + + it("marks the selected channel with aria-selected=true", () => { + mockUseChannels.mockReturnValue({ + data: [makeChannel("ch1", "general")], + }); + + render(); + + const option = screen.getByRole("option", { name: "#general" }); + expect(option).toHaveAttribute("aria-selected", "true"); + }); + + it("marks unselected channels with aria-selected=false", () => { + mockUseChannels.mockReturnValue({ + data: [makeChannel("ch1", "general"), makeChannel("ch2", "random")], + }); + + render(); + + const randomOption = screen.getByRole("option", { name: "#random" }); + expect(randomOption).toHaveAttribute("aria-selected", "false"); + }); + }); + + describe("filtering", () => { + it("excludes the source channel", () => { + mockUseChannels.mockReturnValue({ + data: [ + makeChannel("source", "source-channel"), + makeChannel("target", "target-channel"), + ], + }); + + render( + , + ); + + expect(screen.queryByText("#source-channel")).not.toBeInTheDocument(); + expect(screen.getByText("#target-channel")).toBeInTheDocument(); + }); + + it("excludes archived channels", () => { + mockUseChannels.mockReturnValue({ + data: [ + makeChannel("ch1", "archived", { isArchived: true }), + makeChannel("ch2", "active"), + ], + }); + + render( + , + ); + + expect(screen.queryByText("#archived")).not.toBeInTheDocument(); + expect(screen.getByText("#active")).toBeInTheDocument(); + }); + + it("excludes deactivated channels (isActivated=false)", () => { + mockUseChannels.mockReturnValue({ + data: [ + makeChannel("ch1", "deactivated", { isActivated: false }), + makeChannel("ch2", "active"), + ], + }); + + render( + , + ); + + expect(screen.queryByText("#deactivated")).not.toBeInTheDocument(); + expect(screen.getByText("#active")).toBeInTheDocument(); + }); + + it("filters channels by name on search (case-insensitive)", () => { + mockUseChannels.mockReturnValue({ + data: [ + makeChannel("ch1", "general"), + makeChannel("ch2", "random"), + makeChannel("ch3", "General-announcements"), + ], + }); + + render( + , + ); + + const input = screen.getByTestId("channel-search-input"); + fireEvent.change(input, { target: { value: "gen" } }); + + expect(screen.getByText("#general")).toBeInTheDocument(); + expect(screen.getByText("#General-announcements")).toBeInTheDocument(); + expect(screen.queryByText("#random")).not.toBeInTheDocument(); + }); + + it("shows all channels when search is cleared", () => { + mockUseChannels.mockReturnValue({ + data: [makeChannel("ch1", "general"), makeChannel("ch2", "random")], + }); + + render( + , + ); + + const input = screen.getByTestId("channel-search-input"); + fireEvent.change(input, { target: { value: "gen" } }); + expect(screen.queryByText("#random")).not.toBeInTheDocument(); + + fireEvent.change(input, { target: { value: "" } }); + expect(screen.getByText("#random")).toBeInTheDocument(); + }); + }); + + describe("selection", () => { + it("calls onSelect with the channel id when a row is clicked", () => { + mockUseChannels.mockReturnValue({ + data: [makeChannel("ch1", "general")], + }); + + const onSelect = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("option", { name: "#general" })); + expect(onSelect).toHaveBeenCalledOnce(); + expect(onSelect).toHaveBeenCalledWith("ch1"); + }); + }); + + describe("empty state", () => { + it("renders empty list when no channels are returned", () => { + mockUseChannels.mockReturnValue({ data: [] }); + + render( + , + ); + + const list = screen.getByRole("listbox"); + expect(list).toBeInTheDocument(); + expect(list.children).toHaveLength(0); + }); + + it("renders empty list when all channels are filtered out", () => { + mockUseChannels.mockReturnValue({ + data: [makeChannel("ch1", "general")], + }); + + render( + , + ); + + const input = screen.getByTestId("channel-search-input"); + fireEvent.change(input, { target: { value: "zzznomatch" } }); + + const list = screen.getByRole("listbox"); + expect(list.children).toHaveLength(0); + }); + }); + + describe("undefined data", () => { + it("handles undefined data gracefully (defaults to empty array)", () => { + mockUseChannels.mockReturnValue({ data: undefined }); + + render( + , + ); + + const list = screen.getByRole("listbox"); + expect(list.children).toHaveLength(0); + }); + }); +}); diff --git a/apps/client/src/components/channel/forward/__tests__/ForwardDialog.test.tsx b/apps/client/src/components/channel/forward/__tests__/ForwardDialog.test.tsx new file mode 100644 index 00000000..0f1ec615 --- /dev/null +++ b/apps/client/src/components/channel/forward/__tests__/ForwardDialog.test.tsx @@ -0,0 +1,599 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createElement } from "react"; + +// ── Hoisted mocks (must come before imports that use these) ───────────────── + +const mockToast = vi.hoisted(() => vi.fn()); +const mockToastError = vi.hoisted(() => vi.fn()); +vi.mock("sonner", () => ({ + toast: Object.assign(mockToast, { error: mockToastError }), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (k: string, params?: { count?: number }) => + params?.count !== undefined ? `${k}:${params.count}` : k, + }), +})); + +const mockForwardCreate = vi.hoisted(() => vi.fn()); +vi.mock("@/services/api", () => ({ + api: { + forward: { + create: mockForwardCreate, + }, + }, +})); + +// Mock child components so ForwardDialog tests focus on dialog logic only +vi.mock("../ForwardChannelList", () => ({ + ForwardChannelList: ({ + selectedChannelId, + onSelect, + excludeChannelId, + }: { + selectedChannelId: string | null; + onSelect: (id: string) => void; + excludeChannelId?: string; + }) => ( +
+ + + {selectedChannelId ?? "none"} +
+ ), +})); + +vi.mock("../ForwardPreview", () => ({ + ForwardPreview: ({ messages }: { messages: { id: string }[] }) => ( +
+ Preview ({messages.length} messages) +
+ ), +})); + +// Minimal Dialog mock: renders children when open=true +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ + open, + children, + }: { + open: boolean; + children: React.ReactNode; + onOpenChange?: (open: boolean) => void; + }) => (open ?
{children}
: null), + DialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogTitle: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), + DialogFooter: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ + children, + onClick, + disabled, + variant, + }: { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + variant?: string; + }) => ( + + ), +})); + +import { ForwardDialog } from "../ForwardDialog"; +import type { Message, IMUser } from "@/types/im"; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function makeMessage(id: string, overrides: Partial = {}): Message { + return { + id, + channelId: "source-ch", + senderId: "u1", + content: `Message ${id}`, + type: "text", + isPinned: false, + isEdited: false, + isDeleted: false, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + sender: { + id: "u1", + email: "user@example.com", + username: "user", + displayName: "User One", + status: "online", + isActive: true, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + } as IMUser, + ...overrides, + }; +} + +function makeQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); +} + +function wrap(ui: React.ReactElement, qc?: QueryClient) { + const client = qc ?? makeQueryClient(); + return createElement(QueryClientProvider, { client }, ui); +} + +const defaultProps = { + open: true, + onOpenChange: vi.fn(), + sourceChannelId: "source-ch", + sourceMessages: [makeMessage("m1")], + onSuccess: vi.fn(), +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockForwardCreate.mockResolvedValue({ id: "new-msg" }); +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("ForwardDialog", () => { + describe("rendering", () => { + it("renders nothing when open=false", () => { + render( + wrap( + , + ), + ); + + expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); + }); + + it("renders dialog when open=true", () => { + render(wrap()); + + expect(screen.getByTestId("dialog")).toBeInTheDocument(); + expect(screen.getByTestId("forward-channel-list")).toBeInTheDocument(); + expect(screen.getByTestId("forward-preview")).toBeInTheDocument(); + }); + + it("shows single message title when sourceMessages has 1 message", () => { + render( + wrap( + , + ), + ); + + expect(screen.getByTestId("dialog-title")).toHaveTextContent( + "forward.dialog.titleSingle", + ); + }); + + it("shows bundle title with count when sourceMessages has multiple messages", () => { + render( + wrap( + , + ), + ); + + expect(screen.getByTestId("dialog-title")).toHaveTextContent( + "forward.dialog.titleBundle:2", + ); + }); + + it("passes sourceChannelId as excludeChannelId to ForwardChannelList", () => { + render( + wrap(), + ); + + expect(screen.getByTestId("forward-channel-list")).toHaveAttribute( + "data-exclude", + "source-ch", + ); + }); + + it("passes sourceMessages count to ForwardPreview", () => { + render( + wrap( + , + ), + ); + + expect(screen.getByTestId("forward-preview")).toHaveAttribute( + "data-count", + "2", + ); + }); + }); + + describe("confirm button state", () => { + it("confirm button is disabled when no channel is selected", () => { + render(wrap()); + + const confirmBtn = screen.getByTestId("confirm-button"); + expect(confirmBtn).toBeDisabled(); + }); + + it("confirm button is enabled after a channel is selected", () => { + render(wrap()); + + fireEvent.click(screen.getByTestId("select-ch1")); + + const confirmBtn = screen.getByTestId("confirm-button"); + expect(confirmBtn).not.toBeDisabled(); + }); + }); + + describe("cancel button", () => { + it("cancel button calls onOpenChange(false)", () => { + const onOpenChange = vi.fn(); + render( + wrap(), + ); + + fireEvent.click(screen.getByTestId("cancel-button")); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + }); + + describe("successful forward", () => { + it("calls api.forward.create with correct args", async () => { + render( + wrap( + , + ), + ); + + fireEvent.click(screen.getByTestId("select-ch1")); + fireEvent.click(screen.getByTestId("confirm-button")); + + await waitFor(() => { + expect(mockForwardCreate).toHaveBeenCalledWith({ + targetChannelId: "ch1", + sourceChannelId: "source-ch", + sourceMessageIds: ["m1", "m2"], + }); + }); + }); + + it("shows success toast after successful forward", async () => { + render(wrap()); + + fireEvent.click(screen.getByTestId("select-ch1")); + fireEvent.click(screen.getByTestId("confirm-button")); + + await waitFor(() => { + expect(mockToast).toHaveBeenCalledWith("forward.success"); + }); + }); + + it("closes dialog on success", async () => { + const onOpenChange = vi.fn(); + render( + wrap(), + ); + + fireEvent.click(screen.getByTestId("select-ch1")); + fireEvent.click(screen.getByTestId("confirm-button")); + + await waitFor(() => { + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + }); + + it("calls onSuccess callback after successful forward", async () => { + const onSuccess = vi.fn(); + render(wrap()); + + fireEvent.click(screen.getByTestId("select-ch1")); + fireEvent.click(screen.getByTestId("confirm-button")); + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledOnce(); + }); + }); + + it("does not throw when onSuccess is not provided", async () => { + const { onSuccess: _, ...propsWithoutSuccess } = defaultProps; + render(wrap()); + + fireEvent.click(screen.getByTestId("select-ch1")); + fireEvent.click(screen.getByTestId("confirm-button")); + + await waitFor(() => { + expect(mockToast).toHaveBeenCalledWith("forward.success"); + }); + }); + + it("resets selected channel after success", async () => { + render(wrap()); + + fireEvent.click(screen.getByTestId("select-ch1")); + expect(screen.getByTestId("selected-value")).toHaveTextContent("ch1"); + + fireEvent.click(screen.getByTestId("confirm-button")); + + await waitFor(() => { + expect(mockToast).toHaveBeenCalledWith("forward.success"); + }); + + // After success, selected value should be reset (dialog closes, but + // the internal state is reset to null) + expect(screen.getByTestId("selected-value")).toHaveTextContent("none"); + }); + }); + + describe("error handling", () => { + it("shows error toast on API failure with forward.noWriteAccess", async () => { + mockForwardCreate.mockRejectedValue({ + response: { data: { message: "forward.noWriteAccess" } }, + }); + + render(wrap()); + + fireEvent.click(screen.getByTestId("select-ch1")); + fireEvent.click(screen.getByTestId("confirm-button")); + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith( + "forward.error.noWriteAccess", + ); + }); + }); + + it("maps forward.mixedChannels error code correctly", async () => { + mockForwardCreate.mockRejectedValue({ + response: { data: { message: "forward.mixedChannels" } }, + }); + + render(wrap()); + + fireEvent.click(screen.getByTestId("select-ch1")); + fireEvent.click(screen.getByTestId("confirm-button")); + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith( + "forward.error.mixedChannels", + ); + }); + }); + + it("maps forward.noSourceAccess error code correctly", async () => { + mockForwardCreate.mockRejectedValue({ + response: { data: { message: "forward.noSourceAccess" } }, + }); + + render(wrap()); + + fireEvent.click(screen.getByTestId("select-ch1")); + fireEvent.click(screen.getByTestId("confirm-button")); + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith( + "forward.error.noSourceAccess", + ); + }); + }); + + it("maps forward.tooManySelected error code correctly", async () => { + mockForwardCreate.mockRejectedValue({ + response: { data: { message: "forward.tooManySelected" } }, + }); + + render(wrap()); + + fireEvent.click(screen.getByTestId("select-ch1")); + fireEvent.click(screen.getByTestId("confirm-button")); + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith("forward.tooManySelected"); + }); + }); + + it("maps forward.notAllowed error code correctly", async () => { + mockForwardCreate.mockRejectedValue({ + response: { data: { message: "forward.notAllowed" } }, + }); + + render(wrap()); + + fireEvent.click(screen.getByTestId("select-ch1")); + fireEvent.click(screen.getByTestId("confirm-button")); + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith("forward.error.notAllowed"); + }); + }); + + it("maps forward.notFound error code correctly", async () => { + mockForwardCreate.mockRejectedValue({ + response: { data: { message: "forward.notFound" } }, + }); + + render(wrap()); + + fireEvent.click(screen.getByTestId("select-ch1")); + fireEvent.click(screen.getByTestId("confirm-button")); + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith("forward.error.notFound"); + }); + }); + + it("maps forward.empty error code correctly", async () => { + mockForwardCreate.mockRejectedValue({ + response: { data: { message: "forward.empty" } }, + }); + + render(wrap()); + + fireEvent.click(screen.getByTestId("select-ch1")); + fireEvent.click(screen.getByTestId("confirm-button")); + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith("forward.error.empty"); + }); + }); + + it("falls back to forward.error.notAllowed for unknown error code", async () => { + mockForwardCreate.mockRejectedValue({ + response: { data: { message: "forward.unknownCode" } }, + }); + + render(wrap()); + + fireEvent.click(screen.getByTestId("select-ch1")); + fireEvent.click(screen.getByTestId("confirm-button")); + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith("forward.error.notAllowed"); + }); + }); + + it("falls back to notAllowed when error has no response body (plain Error)", async () => { + mockForwardCreate.mockRejectedValue(new Error("Network error")); + + render(wrap()); + + fireEvent.click(screen.getByTestId("select-ch1")); + fireEvent.click(screen.getByTestId("confirm-button")); + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith("forward.error.notAllowed"); + }); + }); + + it("falls back to notAllowed when error is not an object", async () => { + mockForwardCreate.mockRejectedValue("string error"); + + render(wrap()); + + fireEvent.click(screen.getByTestId("select-ch1")); + fireEvent.click(screen.getByTestId("confirm-button")); + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith("forward.error.notAllowed"); + }); + }); + + it("falls back to notAllowed when error object has non-string message", async () => { + mockForwardCreate.mockRejectedValue({ message: 42 }); + + render(wrap()); + + fireEvent.click(screen.getByTestId("select-ch1")); + fireEvent.click(screen.getByTestId("confirm-button")); + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith("forward.error.notAllowed"); + }); + }); + + it("does not close dialog on error", async () => { + mockForwardCreate.mockRejectedValue({ + response: { data: { message: "forward.noWriteAccess" } }, + }); + + const onOpenChange = vi.fn(); + render( + wrap(), + ); + + fireEvent.click(screen.getByTestId("select-ch1")); + fireEvent.click(screen.getByTestId("confirm-button")); + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalled(); + }); + + // Should NOT have called onOpenChange(false) + expect(onOpenChange).not.toHaveBeenCalledWith(false); + }); + + it("uses error.message as code when response.data.message is absent", async () => { + mockForwardCreate.mockRejectedValue({ + message: "forward.noWriteAccess", + }); + + render(wrap()); + + fireEvent.click(screen.getByTestId("select-ch1")); + fireEvent.click(screen.getByTestId("confirm-button")); + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith( + "forward.error.noWriteAccess", + ); + }); + }); + }); + + describe("query invalidation on success", () => { + it("invalidates channelMessages query for target channel", async () => { + const qc = makeQueryClient(); + const invalidateSpy = vi.spyOn(qc, "invalidateQueries"); + + render( + wrap( + , + qc, + ), + ); + + fireEvent.click(screen.getByTestId("select-ch1")); + fireEvent.click(screen.getByTestId("confirm-button")); + + await waitFor(() => { + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ["channelMessages", "ch1"], + }); + }); + }); + }); +}); diff --git a/apps/client/src/components/channel/forward/__tests__/ForwardPreview.test.tsx b/apps/client/src/components/channel/forward/__tests__/ForwardPreview.test.tsx new file mode 100644 index 00000000..76936949 --- /dev/null +++ b/apps/client/src/components/channel/forward/__tests__/ForwardPreview.test.tsx @@ -0,0 +1,233 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; + +// ── Mocks ────────────────────────────────────────────────────────────────── + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (k: string, params?: { count?: number }) => + params?.count !== undefined ? `${k}:${params.count}` : k, + }), +})); + +// Lightweight UserAvatar mock — just renders the name so tests can assert it +vi.mock("@/components/ui/user-avatar", () => ({ + UserAvatar: ({ + name, + username, + }: { + name?: string | null; + username?: string; + }) => {name ?? username ?? ""}, +})); + +import { ForwardPreview } from "../ForwardPreview"; +import type { Message, IMUser } from "@/types/im"; + +function makeMessage( + id: string, + content: string, + sender?: Partial, +): Message { + return { + id, + channelId: "ch1", + senderId: sender?.id ?? null, + content, + type: "text", + isPinned: false, + isEdited: false, + isDeleted: false, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + sender: sender + ? ({ + id: sender.id ?? "u1", + email: sender.email ?? "user@example.com", + username: sender.username ?? "user", + displayName: sender.displayName, + avatarUrl: sender.avatarUrl, + status: "online", + isActive: true, + userType: sender.userType ?? "human", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + } as IMUser) + : undefined, + }; +} + +describe("ForwardPreview", () => { + describe("single message", () => { + it("renders sender displayName and message content", () => { + const msg = makeMessage("m1", "Hello world", { + id: "u1", + username: "alice", + displayName: "Alice", + }); + + render(); + + // "Alice" appears in both the avatar mock and the name span + const aliceElements = screen.getAllByText("Alice"); + expect(aliceElements.length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("Hello world")).toBeInTheDocument(); + }); + + it("falls back to username when displayName is not set", () => { + const msg = makeMessage("m1", "Hi there", { + id: "u1", + username: "bob", + displayName: undefined, + }); + + render(); + + const bobElements = screen.getAllByText("bob"); + expect(bobElements.length).toBeGreaterThanOrEqual(1); + }); + + it("renders message content when sender is undefined", () => { + const msg = makeMessage("m1", "System message"); + + render(); + + expect(screen.getByText("System message")).toBeInTheDocument(); + }); + }); + + describe("bundle preview (multiple messages)", () => { + it("renders bundle title with count", () => { + const msgs = [ + makeMessage("m1", "First", { id: "u1", displayName: "Alice" }), + makeMessage("m2", "Second", { id: "u2", displayName: "Bob" }), + ]; + + render(); + + expect(screen.getByText("forward.bundle.title:2")).toBeInTheDocument(); + }); + + it("renders up to 3 messages in the preview list", () => { + const msgs = [ + makeMessage("m1", "First", { id: "u1", displayName: "Alice" }), + makeMessage("m2", "Second", { id: "u2", displayName: "Bob" }), + makeMessage("m3", "Third", { id: "u3", displayName: "Carol" }), + ]; + + render(); + + // Each name appears in both the avatar span and the font-medium span + expect(screen.getAllByText("Alice").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Bob").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Carol").length).toBeGreaterThanOrEqual(1); + }); + + it("shows the '…viewAll' indicator when count > 3", () => { + const msgs = [ + makeMessage("m1", "First", { id: "u1", displayName: "Alice" }), + makeMessage("m2", "Second", { id: "u2", displayName: "Bob" }), + makeMessage("m3", "Third", { id: "u3", displayName: "Carol" }), + makeMessage("m4", "Fourth", { id: "u4", displayName: "Dave" }), + ]; + + render(); + + // Only first 3 senders visible in the list + expect(screen.getAllByText("Alice").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Bob").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Carol").length).toBeGreaterThanOrEqual(1); + expect(screen.queryByText("Dave")).not.toBeInTheDocument(); + + // "…View all" indicator + expect(screen.getByText(/forward\.bundle\.viewAll/)).toBeInTheDocument(); + }); + + it("does NOT show the '…viewAll' indicator when count === 3", () => { + const msgs = [ + makeMessage("m1", "First", { id: "u1", displayName: "Alice" }), + makeMessage("m2", "Second", { id: "u2", displayName: "Bob" }), + makeMessage("m3", "Third", { id: "u3", displayName: "Carol" }), + ]; + + render(); + + expect( + screen.queryByText(/forward\.bundle\.viewAll/), + ).not.toBeInTheDocument(); + }); + + it("does NOT show the '…viewAll' indicator when count === 2", () => { + const msgs = [ + makeMessage("m1", "First", { id: "u1", displayName: "Alice" }), + makeMessage("m2", "Second", { id: "u2", displayName: "Bob" }), + ]; + + render(); + + expect( + screen.queryByText(/forward\.bundle\.viewAll/), + ).not.toBeInTheDocument(); + }); + + it("truncates content to 80 chars in the list", () => { + const longContent = "a".repeat(100); + const msg = makeMessage("m1", longContent, { + id: "u1", + displayName: "Alice", + }); + const msgs = [ + msg, + makeMessage("m2", "Short", { id: "u2", displayName: "Bob" }), + ]; + + render(); + + expect(screen.getByText("a".repeat(80))).toBeInTheDocument(); + }); + + it("renders empty string for null/undefined content in bundle", () => { + const msg = makeMessage("m1", "", { id: "u1", displayName: "Alice" }); + // Override content to null-like value + msg.content = null as unknown as string; + const msgs = [ + msg, + makeMessage("m2", "Other", { id: "u2", displayName: "Bob" }), + ]; + + render(); + + // No crash — component uses optional chain + ?? "" + expect(screen.getAllByText("Alice").length).toBeGreaterThanOrEqual(1); + }); + + it("falls back to username in bundle when displayName is not set", () => { + const msgs = [ + makeMessage("m1", "Hello", { + id: "u1", + username: "bob", + displayName: undefined, + }), + makeMessage("m2", "World", { id: "u2", displayName: "Carol" }), + ]; + + render(); + + // "bob" appears in both avatar mock and name span + expect(screen.getAllByText("bob").length).toBeGreaterThanOrEqual(1); + }); + + it("renders bundle with sender that has no sender object", () => { + // Cover m.sender?.displayName ?? null when sender is undefined + const msgs = [ + makeMessage("m1", "Hello"), + makeMessage("m2", "World", { id: "u2", displayName: "Carol" }), + ]; + + render(); + + // Second sender's name is shown + expect(screen.getAllByText("Carol").length).toBeGreaterThanOrEqual(1); + }); + }); +}); diff --git a/apps/client/src/i18n/locales/en/channel.json b/apps/client/src/i18n/locales/en/channel.json index 23975016..c10687db 100644 --- a/apps/client/src/i18n/locales/en/channel.json +++ b/apps/client/src/i18n/locales/en/channel.json @@ -279,6 +279,7 @@ "bar": "{{count}} selected", "cancel": "Cancel" }, + "success": "Forwarded.", "tooManySelected": "You can forward up to 100 messages at once.", "card": { "fromChannel": "Forwarded from #{{channelName}}" diff --git a/apps/client/src/i18n/locales/zh-CN/channel.json b/apps/client/src/i18n/locales/zh-CN/channel.json index 24d64c85..7d9f8837 100644 --- a/apps/client/src/i18n/locales/zh-CN/channel.json +++ b/apps/client/src/i18n/locales/zh-CN/channel.json @@ -276,6 +276,7 @@ "bar": "已选 {{count}} 条", "cancel": "取消" }, + "success": "已转发", "tooManySelected": "一次最多转发 100 条消息", "card": { "fromChannel": "转自 #{{channelName}}" From 6b74801e340716bc4fabb2d04968bd75e9e99bee Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 2 May 2026 16:54:36 +0800 Subject: [PATCH 14/23] feat(client): render forwarded message cards and bundle viewer 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 --- .../channel/forward/ForwardBundleViewer.tsx | 64 ++ .../channel/forward/ForwardItemBody.tsx | 69 ++ .../channel/forward/ForwardedMessageCard.tsx | 99 +++ .../__tests__/ForwardBundleViewer.test.tsx | 398 +++++++++++ .../__tests__/ForwardedMessageCard.test.tsx | 616 ++++++++++++++++++ 5 files changed, 1246 insertions(+) create mode 100644 apps/client/src/components/channel/forward/ForwardBundleViewer.tsx create mode 100644 apps/client/src/components/channel/forward/ForwardItemBody.tsx create mode 100644 apps/client/src/components/channel/forward/ForwardedMessageCard.tsx create mode 100644 apps/client/src/components/channel/forward/__tests__/ForwardBundleViewer.test.tsx create mode 100644 apps/client/src/components/channel/forward/__tests__/ForwardedMessageCard.test.tsx diff --git a/apps/client/src/components/channel/forward/ForwardBundleViewer.tsx b/apps/client/src/components/channel/forward/ForwardBundleViewer.tsx new file mode 100644 index 00000000..cb79cf1d --- /dev/null +++ b/apps/client/src/components/channel/forward/ForwardBundleViewer.tsx @@ -0,0 +1,64 @@ +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { api } from "@/services/api"; +import { ForwardItemBody } from "./ForwardItemBody"; +import type { ForwardItem } from "@/types/im"; + +interface Props { + messageId: string; + channelName: string | null; + onOpenChange: (open: boolean) => void; + onJump?: (item: ForwardItem) => void; +} + +export function ForwardBundleViewer({ + messageId, + channelName, + onOpenChange, + onJump, +}: Props) { + const { t } = useTranslation("channel"); + const { data, isLoading, isError } = useQuery({ + queryKey: ["forwardItems", messageId], + queryFn: () => api.forward.getItems(messageId), + }); + + return ( + + + + + {channelName + ? t("forward.bundle.modalTitle", { channelName }) + : t("forward.source.unavailable")} + + + {isLoading && ( +
+ {t("forward.source.unavailable")} +
+ )} + {isError && ( +
+ {t("forward.error.notFound")} +
+ )} + {data && ( +
    + {data.map((item) => ( +
  • + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/apps/client/src/components/channel/forward/ForwardItemBody.tsx b/apps/client/src/components/channel/forward/ForwardItemBody.tsx new file mode 100644 index 00000000..cc268b6b --- /dev/null +++ b/apps/client/src/components/channel/forward/ForwardItemBody.tsx @@ -0,0 +1,69 @@ +import { useTranslation } from "react-i18next"; +import type { ForwardItem } from "@/types/im"; +import { UserAvatar } from "@/components/ui/user-avatar"; +import { AstRenderer } from "../AstRenderer"; + +interface Props { + item: ForwardItem; + showJumpLink?: boolean; + onJump?: (item: ForwardItem) => void; +} + +export function ForwardItemBody({ item, showJumpLink = false, onJump }: Props) { + const { t } = useTranslation("channel"); + + const senderName = + item.sourceSender?.displayName ?? item.sourceSender?.username ?? "?"; + + return ( +
+
+ + {senderName} + + {new Date(item.sourceCreatedAt).toLocaleString()} + +
+
+ {item.contentAstSnapshot ? ( + + ) : ( + + {item.contentSnapshot ?? ""} + + )} +
+ {item.attachmentsSnapshot.length > 0 && ( + + )} + {showJumpLink && item.canJumpToOriginal && item.sourceMessageId && ( + + )} +
+ ); +} diff --git a/apps/client/src/components/channel/forward/ForwardedMessageCard.tsx b/apps/client/src/components/channel/forward/ForwardedMessageCard.tsx new file mode 100644 index 00000000..5f5f617c --- /dev/null +++ b/apps/client/src/components/channel/forward/ForwardedMessageCard.tsx @@ -0,0 +1,99 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "@tanstack/react-router"; +import type { Message, ForwardItem } from "@/types/im"; +import { UserAvatar } from "@/components/ui/user-avatar"; +import { ForwardItemBody } from "./ForwardItemBody"; +import { ForwardBundleViewer } from "./ForwardBundleViewer"; + +interface Props { + message: Message; +} + +export function ForwardedMessageCard({ message }: Props) { + const { t } = useTranslation("channel"); + const navigate = useNavigate(); + const [bundleOpen, setBundleOpen] = useState(false); + + const fwd = message.forward; + if (!fwd) return null; + + const headerText = fwd.sourceChannelName + ? t("forward.card.fromChannel", { channelName: fwd.sourceChannelName }) + : t("forward.source.unavailable"); + + const jumpToOriginal = (item: ForwardItem) => { + if (!item.sourceMessageId) return; + void navigate({ + to: "/channels/$channelId", + params: { channelId: item.sourceChannelId }, + search: { message: item.sourceMessageId }, + }); + }; + + if (fwd.kind === "single") { + const item = fwd.items[0]; + if (!item) return null; + return ( +
+
{headerText}
+
+ +
+
+ ); + } + + // bundle + const previews = fwd.items.slice(0, 3); + return ( + <> +
+
{headerText}
+ +
+ {bundleOpen && ( + + )} + + ); +} diff --git a/apps/client/src/components/channel/forward/__tests__/ForwardBundleViewer.test.tsx b/apps/client/src/components/channel/forward/__tests__/ForwardBundleViewer.test.tsx new file mode 100644 index 00000000..9b8579fe --- /dev/null +++ b/apps/client/src/components/channel/forward/__tests__/ForwardBundleViewer.test.tsx @@ -0,0 +1,398 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createElement } from "react"; + +// ── Hoisted mocks ───────────────────────────────────────────────────────────── + +const mockGetItems = vi.hoisted(() => vi.fn()); + +vi.mock("@/services/api", () => ({ + api: { + forward: { + getItems: mockGetItems, + }, + }, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (k: string, params?: Record) => { + if (params && Object.keys(params).length > 0) { + return `${k}:${JSON.stringify(params)}`; + } + return k; + }, + }), +})); + +vi.mock("@/components/ui/user-avatar", () => ({ + UserAvatar: ({ + name, + username, + }: { + name?: string | null; + username?: string | null; + }) => {name ?? username ?? ""}, +})); + +vi.mock("@/components/channel/AstRenderer", () => ({ + AstRenderer: ({ ast }: { ast: unknown }) => ( +
+ ), +})); + +// Minimal Dialog mock — always renders children (open=true is fixed in component) +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ + children, + onOpenChange, + }: { + open: boolean; + children: React.ReactNode; + onOpenChange?: (open: boolean) => void; + }) => ( +
+ {children} + +
+ ), + DialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogTitle: ({ children }: { children: React.ReactNode }) => ( +

{children}

+ ), +})); + +// ── Component import ────────────────────────────────────────────────────────── + +import { ForwardBundleViewer } from "../ForwardBundleViewer"; +import type { ForwardItem } from "@/types/im"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeItem( + position: number, + overrides: Partial = {}, +): ForwardItem { + return { + position, + sourceMessageId: `msg-src-${position}`, + sourceChannelId: "ch-src-1", + sourceChannelName: "general", + sourceWorkspaceId: "ws-1", + sourceSender: { + id: `u${position}`, + username: `user${position}`, + displayName: `User ${position}`, + avatarUrl: null, + }, + sourceCreatedAt: "2026-01-01T00:00:00Z", + sourceSeqId: String(position), + sourceType: "text", + contentSnapshot: `Message ${position}`, + contentAstSnapshot: null, + attachmentsSnapshot: [], + canJumpToOriginal: true, + truncated: false, + ...overrides, + }; +} + +function wrapper({ children }: { children: React.ReactNode }) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return createElement(QueryClientProvider, { client: qc }, children); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("ForwardBundleViewer", () => { + beforeEach(() => { + mockGetItems.mockReset(); + }); + + describe("loading state", () => { + it("shows loading indicator while query is in flight", () => { + // Never resolves — query stays loading + mockGetItems.mockReturnValue(new Promise(() => {})); + + render( + , + { wrapper }, + ); + + // Loading state renders the unavailable text as a placeholder + expect( + screen.getByText("forward.source.unavailable"), + ).toBeInTheDocument(); + }); + }); + + describe("success state", () => { + it("renders all items returned by api.forward.getItems", async () => { + mockGetItems.mockResolvedValue([makeItem(1), makeItem(2), makeItem(3)]); + + render( + , + { wrapper }, + ); + + await waitFor(() => { + expect(screen.getAllByText("User 1").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("User 2").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("User 3").length).toBeGreaterThanOrEqual(1); + }); + }); + + it("renders items in position order (sorted by DOM insertion order)", async () => { + // Items returned out-of-order from API; component renders them as-is + mockGetItems.mockResolvedValue([makeItem(3), makeItem(1), makeItem(2)]); + + render( + , + { wrapper }, + ); + + await waitFor(() => { + const items = screen.getAllByRole("listitem"); + expect(items).toHaveLength(3); + }); + }); + + it("renders content snapshot as plaintext", async () => { + mockGetItems.mockResolvedValue([ + makeItem(1, { + contentSnapshot: "Plain content here", + contentAstSnapshot: null, + }), + ]); + + render( + , + { wrapper }, + ); + + await waitFor(() => { + expect(screen.getByText("Plain content here")).toBeInTheDocument(); + }); + }); + + it("renders AstRenderer when contentAstSnapshot is present", async () => { + const ast = { root: { type: "root", children: [] } }; + mockGetItems.mockResolvedValue([ + makeItem(1, { contentAstSnapshot: ast, contentSnapshot: null }), + ]); + + render( + , + { wrapper }, + ); + + await waitFor(() => { + expect(screen.getByTestId("ast-renderer")).toBeInTheDocument(); + }); + }); + + it("renders attachment links", async () => { + mockGetItems.mockResolvedValue([ + makeItem(1, { + attachmentsSnapshot: [ + { + originalAttachmentId: "att-1", + fileName: "report.pdf", + fileUrl: "https://example.com/report.pdf", + fileKey: null, + fileSize: 5000, + mimeType: "application/pdf", + thumbnailUrl: null, + width: null, + height: null, + }, + ], + }), + ]); + + render( + , + { wrapper }, + ); + + await waitFor(() => { + const link = screen.getByRole("link", { name: "report.pdf" }); + expect(link).toHaveAttribute("href", "https://example.com/report.pdf"); + }); + }); + + it("shows jump link when canJumpToOriginal is true and calls onJump", async () => { + const onJump = vi.fn(); + const item = makeItem(1, { canJumpToOriginal: true }); + mockGetItems.mockResolvedValue([item]); + + render( + , + { wrapper }, + ); + + await waitFor(() => { + expect(screen.getByText("forward.source.jumpTo")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText("forward.source.jumpTo")); + expect(onJump).toHaveBeenCalledWith( + expect.objectContaining({ position: 1 }), + ); + }); + + it("hides jump link when canJumpToOriginal is false", async () => { + mockGetItems.mockResolvedValue([ + makeItem(1, { canJumpToOriginal: false }), + ]); + + render( + , + { wrapper }, + ); + + await waitFor(() => { + expect(screen.getAllByText("User 1").length).toBeGreaterThanOrEqual(1); + }); + + expect( + screen.queryByText("forward.source.jumpTo"), + ).not.toBeInTheDocument(); + }); + }); + + describe("error state", () => { + it("renders error message when query fails", async () => { + mockGetItems.mockRejectedValue(new Error("Network error")); + + render( + , + { wrapper }, + ); + + await waitFor(() => { + expect(screen.getByText("forward.error.notFound")).toBeInTheDocument(); + }); + }); + }); + + describe("dialog title", () => { + it("shows channel name in title when channelName is provided", () => { + mockGetItems.mockReturnValue(new Promise(() => {})); + + render( + , + { wrapper }, + ); + + expect(screen.getByTestId("dialog-title")).toHaveTextContent( + "forward.bundle.modalTitle", + ); + }); + + it("shows 'source unavailable' in title when channelName is null", () => { + mockGetItems.mockReturnValue(new Promise(() => {})); + + render( + , + { wrapper }, + ); + + expect(screen.getByTestId("dialog-title")).toHaveTextContent( + "forward.source.unavailable", + ); + }); + }); + + describe("dialog close", () => { + it("calls onOpenChange(false) when dialog requests close", () => { + mockGetItems.mockReturnValue(new Promise(() => {})); + const onOpenChange = vi.fn(); + + render( + , + { wrapper }, + ); + + fireEvent.click(screen.getByTestId("dialog-close")); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + }); + + describe("api call", () => { + it("calls api.forward.getItems with the correct messageId", () => { + mockGetItems.mockReturnValue(new Promise(() => {})); + + render( + , + { wrapper }, + ); + + expect(mockGetItems).toHaveBeenCalledWith("msg-42"); + }); + }); +}); diff --git a/apps/client/src/components/channel/forward/__tests__/ForwardedMessageCard.test.tsx b/apps/client/src/components/channel/forward/__tests__/ForwardedMessageCard.test.tsx new file mode 100644 index 00000000..090bf40b --- /dev/null +++ b/apps/client/src/components/channel/forward/__tests__/ForwardedMessageCard.test.tsx @@ -0,0 +1,616 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; + +// ── Hoisted mocks ───────────────────────────────────────────────────────────── + +const mockNavigate = vi.hoisted(() => vi.fn()); + +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => mockNavigate, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (k: string, params?: Record) => { + if (params && Object.keys(params).length > 0) { + return `${k}:${JSON.stringify(params)}`; + } + return k; + }, + }), +})); + +vi.mock("@/components/ui/user-avatar", () => ({ + UserAvatar: ({ + name, + username, + }: { + name?: string | null; + username?: string | null; + }) => {name ?? username ?? ""}, +})); + +// Mock AstRenderer so tests don't need full Lexical setup. +// Use the absolute alias path so the mock intercepts the import in ForwardItemBody +// (which lives one level up from this test file and imports "../AstRenderer"). +vi.mock("@/components/channel/AstRenderer", () => ({ + AstRenderer: ({ ast }: { ast: unknown }) => ( +
+ ), +})); + +// Mock ForwardBundleViewer to isolate ForwardedMessageCard behaviour. +// Exposes an "onJump" trigger button so tests can exercise the jumpToOriginal callback. +vi.mock("../ForwardBundleViewer", () => ({ + ForwardBundleViewer: ({ + messageId, + channelName, + onOpenChange, + onJump, + }: { + messageId: string; + channelName: string | null; + onOpenChange: (v: boolean) => void; + onJump?: (item: unknown) => void; + }) => ( +
+ + {onJump && ( + + )} +
+ ), +})); + +// ── Component + type imports (after mocks) ──────────────────────────────────── + +import { ForwardedMessageCard } from "../ForwardedMessageCard"; +import type { Message, ForwardPayload, ForwardItem } from "@/types/im"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeForwardItem(overrides: Partial = {}): ForwardItem { + return { + position: 1, + sourceMessageId: "msg-src-1", + sourceChannelId: "ch-src-1", + sourceChannelName: "general", + sourceWorkspaceId: "ws-1", + sourceSender: { + id: "u1", + username: "alice", + displayName: "Alice", + avatarUrl: null, + }, + sourceCreatedAt: "2026-01-01T00:00:00Z", + sourceSeqId: "1", + sourceType: "text", + contentSnapshot: "Hello world", + contentAstSnapshot: null, + attachmentsSnapshot: [], + canJumpToOriginal: true, + truncated: false, + ...overrides, + }; +} + +function makeMessage( + fwd?: ForwardPayload, + overrides: Partial = {}, +): Message { + return { + id: "msg-1", + channelId: "ch-1", + senderId: "u1", + content: "", + type: "forward", + isPinned: false, + isEdited: false, + isDeleted: false, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + forward: fwd, + ...overrides, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("ForwardedMessageCard", () => { + beforeEach(() => { + mockNavigate.mockClear(); + }); + + describe("no forward payload", () => { + it("returns null when message.forward is undefined", () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + }); + + describe("single forward", () => { + it("renders 'forwarded from channel' header", () => { + const fwd: ForwardPayload = { + kind: "single", + count: 1, + sourceChannelId: "ch-src-1", + sourceChannelName: "general", + truncated: false, + items: [makeForwardItem()], + }; + render(); + expect( + screen.getByText(/forward\.card\.fromChannel/), + ).toBeInTheDocument(); + }); + + it("renders 'source unavailable' header when channelName is null", () => { + const fwd: ForwardPayload = { + kind: "single", + count: 1, + sourceChannelId: "ch-src-1", + sourceChannelName: null, + truncated: false, + items: [makeForwardItem()], + }; + render(); + expect( + screen.getByText("forward.source.unavailable"), + ).toBeInTheDocument(); + }); + + it("renders sender name and content from item", () => { + const fwd: ForwardPayload = { + kind: "single", + count: 1, + sourceChannelId: "ch-src-1", + sourceChannelName: "general", + truncated: false, + items: [makeForwardItem({ contentSnapshot: "Test message content" })], + }; + render(); + // "Alice" appears in user-avatar mock and in the name span + expect(screen.getAllByText("Alice").length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("Test message content")).toBeInTheDocument(); + }); + + it("renders AstRenderer when contentAstSnapshot is non-null", () => { + const ast = { root: { type: "root", children: [] } }; + const fwd: ForwardPayload = { + kind: "single", + count: 1, + sourceChannelId: "ch-src-1", + sourceChannelName: "general", + truncated: false, + items: [ + makeForwardItem({ contentAstSnapshot: ast, contentSnapshot: null }), + ], + }; + render(); + expect(screen.getByTestId("ast-renderer")).toBeInTheDocument(); + }); + + it("shows jump link when canJumpToOriginal is true", () => { + const fwd: ForwardPayload = { + kind: "single", + count: 1, + sourceChannelId: "ch-src-1", + sourceChannelName: "general", + truncated: false, + items: [makeForwardItem({ canJumpToOriginal: true })], + }; + render(); + expect(screen.getByText("forward.source.jumpTo")).toBeInTheDocument(); + }); + + it("hides jump link when canJumpToOriginal is false", () => { + const fwd: ForwardPayload = { + kind: "single", + count: 1, + sourceChannelId: "ch-src-1", + sourceChannelName: "general", + truncated: false, + items: [makeForwardItem({ canJumpToOriginal: false })], + }; + render(); + expect( + screen.queryByText("forward.source.jumpTo"), + ).not.toBeInTheDocument(); + }); + + it("hides jump link when sourceMessageId is null", () => { + const fwd: ForwardPayload = { + kind: "single", + count: 1, + sourceChannelId: "ch-src-1", + sourceChannelName: "general", + truncated: false, + items: [ + makeForwardItem({ canJumpToOriginal: true, sourceMessageId: null }), + ], + }; + render(); + expect( + screen.queryByText("forward.source.jumpTo"), + ).not.toBeInTheDocument(); + }); + + it("calls navigate with correct params when jump button is clicked", () => { + const fwd: ForwardPayload = { + kind: "single", + count: 1, + sourceChannelId: "ch-src-1", + sourceChannelName: "general", + truncated: false, + items: [ + makeForwardItem({ + canJumpToOriginal: true, + sourceMessageId: "msg-src-1", + sourceChannelId: "ch-src-1", + }), + ], + }; + render(); + fireEvent.click(screen.getByText("forward.source.jumpTo")); + expect(mockNavigate).toHaveBeenCalledWith({ + to: "/channels/$channelId", + params: { channelId: "ch-src-1" }, + search: { message: "msg-src-1" }, + }); + }); + + it("returns null defensively when single forward has empty items array", () => { + const fwd: ForwardPayload = { + kind: "single", + count: 1, + sourceChannelId: "ch-src-1", + sourceChannelName: "general", + truncated: false, + items: [], + }; + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it("renders attachment links when attachmentsSnapshot is non-empty", () => { + const fwd: ForwardPayload = { + kind: "single", + count: 1, + sourceChannelId: "ch-src-1", + sourceChannelName: "general", + truncated: false, + items: [ + makeForwardItem({ + attachmentsSnapshot: [ + { + originalAttachmentId: "att-1", + fileName: "document.pdf", + fileUrl: "https://example.com/document.pdf", + fileKey: "key-1", + fileSize: 12345, + mimeType: "application/pdf", + thumbnailUrl: null, + width: null, + height: null, + }, + ], + }), + ], + }; + render(); + const link = screen.getByRole("link", { name: "document.pdf" }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute("href", "https://example.com/document.pdf"); + }); + + it("renders plaintext fallback when contentAstSnapshot is null", () => { + const fwd: ForwardPayload = { + kind: "single", + count: 1, + sourceChannelId: "ch-src-1", + sourceChannelName: "general", + truncated: false, + items: [ + makeForwardItem({ + contentAstSnapshot: null, + contentSnapshot: "Plain text content", + }), + ], + }; + render(); + expect(screen.getByText("Plain text content")).toBeInTheDocument(); + expect(screen.queryByTestId("ast-renderer")).not.toBeInTheDocument(); + }); + + it("renders empty string when both snapshot fields are null", () => { + const fwd: ForwardPayload = { + kind: "single", + count: 1, + sourceChannelId: "ch-src-1", + sourceChannelName: "general", + truncated: false, + items: [ + makeForwardItem({ + contentAstSnapshot: null, + contentSnapshot: null, + }), + ], + }; + // Should not throw + const { container } = render( + , + ); + expect(container).toBeTruthy(); + }); + + it("falls back to username when sourceSender has no displayName", () => { + const fwd: ForwardPayload = { + kind: "single", + count: 1, + sourceChannelId: "ch-src-1", + sourceChannelName: "general", + truncated: false, + items: [ + makeForwardItem({ + sourceSender: { + id: "u2", + username: "bob", + displayName: null, + avatarUrl: null, + }, + }), + ], + }; + render(); + expect(screen.getAllByText("bob").length).toBeGreaterThanOrEqual(1); + }); + + it("renders '?' when sourceSender is null", () => { + const fwd: ForwardPayload = { + kind: "single", + count: 1, + sourceChannelId: "ch-src-1", + sourceChannelName: "general", + truncated: false, + items: [makeForwardItem({ sourceSender: null })], + }; + render(); + // Should not throw — "?" is rendered + expect(screen.getAllByText("?").length).toBeGreaterThanOrEqual(1); + }); + }); + + describe("bundle forward", () => { + function makeBundleFwd( + count: number, + items?: Partial[], + ): ForwardPayload { + const itemList = ( + items ?? [{ position: 1 }, { position: 2 }, { position: 3 }] + ).map((overrides, i) => + makeForwardItem({ + position: i + 1, + contentSnapshot: `Message ${i + 1}`, + sourceSender: { + id: `u${i + 1}`, + username: `user${i + 1}`, + displayName: `User ${i + 1}`, + avatarUrl: null, + }, + ...overrides, + }), + ); + return { + kind: "bundle", + count, + sourceChannelId: "ch-src-1", + sourceChannelName: "general", + truncated: false, + items: itemList, + }; + } + + it("renders bundle title with count", () => { + render(); + expect(screen.getByText(/forward\.bundle\.title/)).toBeInTheDocument(); + }); + + it("renders up to 3 preview rows", () => { + const fwd = makeBundleFwd(3); + render(); + expect(screen.getAllByText("User 1").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("User 2").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("User 3").length).toBeGreaterThanOrEqual(1); + }); + + it("shows 'View all' indicator when count > 3", () => { + // 4 items in payload but only 3 are in items array (matching real API behaviour + // where items is a preview subset); count=4 makes count > previews.length + const fwd: ForwardPayload = { + kind: "bundle", + count: 4, + sourceChannelId: "ch-src-1", + sourceChannelName: "general", + truncated: false, + items: [ + makeForwardItem({ + position: 1, + sourceSender: { + id: "u1", + username: "u1", + displayName: "User 1", + avatarUrl: null, + }, + }), + makeForwardItem({ + position: 2, + sourceSender: { + id: "u2", + username: "u2", + displayName: "User 2", + avatarUrl: null, + }, + }), + makeForwardItem({ + position: 3, + sourceSender: { + id: "u3", + username: "u3", + displayName: "User 3", + avatarUrl: null, + }, + }), + ], + }; + render(); + expect(screen.getByText("forward.bundle.viewAll")).toBeInTheDocument(); + }); + + it("does NOT show 'View all' when count <= previews.length", () => { + render(); + expect( + screen.queryByText("forward.bundle.viewAll"), + ).not.toBeInTheDocument(); + }); + + it("opens ForwardBundleViewer when the card is clicked", () => { + render(); + expect(screen.queryByTestId("bundle-viewer")).not.toBeInTheDocument(); + // The bundle button contains title text + const button = screen.getByRole("button"); + fireEvent.click(button); + expect(screen.getByTestId("bundle-viewer")).toBeInTheDocument(); + }); + + it("passes messageId and channelName to ForwardBundleViewer", () => { + const msg = makeMessage(makeBundleFwd(3)); + render(); + fireEvent.click(screen.getByRole("button")); + const viewer = screen.getByTestId("bundle-viewer"); + expect(viewer).toHaveAttribute("data-message-id", "msg-1"); + expect(viewer).toHaveAttribute("data-channel-name", "general"); + }); + + it("closes viewer when onOpenChange(false) is called", () => { + render(); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByTestId("bundle-viewer")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Close")); + expect(screen.queryByTestId("bundle-viewer")).not.toBeInTheDocument(); + }); + + it("renders 'source unavailable' header when sourceChannelName is null in bundle", () => { + const fwd: ForwardPayload = { + kind: "bundle", + count: 2, + sourceChannelId: "ch-src-1", + sourceChannelName: null, + truncated: false, + items: [ + makeForwardItem({ position: 1 }), + makeForwardItem({ position: 2 }), + ], + }; + render(); + expect( + screen.getByText("forward.source.unavailable"), + ).toBeInTheDocument(); + }); + + it("passes null channelName to viewer when sourceChannelName is null", () => { + const fwd: ForwardPayload = { + kind: "bundle", + count: 2, + sourceChannelId: "ch-src-1", + sourceChannelName: null, + truncated: false, + items: [ + makeForwardItem({ position: 1 }), + makeForwardItem({ position: 2 }), + ], + }; + render(); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByTestId("bundle-viewer")).toHaveAttribute( + "data-channel-name", + "null", + ); + }); + + it("jumpToOriginal does nothing when sourceMessageId is null (via onJump callback)", () => { + render(); + fireEvent.click( + screen.getByRole("button", { name: /forward\.bundle\.title/ }), + ); + // Trigger onJump with a null sourceMessageId — navigate should NOT be called + fireEvent.click(screen.getByTestId("trigger-jump-null")); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it("renders bundle preview rows with null sourceSender gracefully", () => { + const fwd: ForwardPayload = { + kind: "bundle", + count: 1, + sourceChannelId: "ch-src-1", + sourceChannelName: "general", + truncated: false, + items: [ + makeForwardItem({ + position: 1, + sourceSender: null, + contentSnapshot: "hi", + }), + ], + }; + render(); + // Should render without crash; "?" appears as fallback + expect(screen.getAllByText("?").length).toBeGreaterThanOrEqual(1); + }); + + it("renders empty string in bundle preview when contentSnapshot is null", () => { + const fwd: ForwardPayload = { + kind: "bundle", + count: 1, + sourceChannelId: "ch-src-1", + sourceChannelName: "general", + truncated: false, + items: [makeForwardItem({ position: 1, contentSnapshot: null })], + }; + // Should not throw — contentSnapshot?.slice(0, 80) ?? "" handles null + const { container } = render( + , + ); + expect(container).toBeTruthy(); + }); + }); +}); From 5869067214835afccc4948d489183d62c0f50d12 Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 2 May 2026 17:18:15 +0800 Subject: [PATCH 15/23] feat(client): add forward eligibility helper and SelectionActionBar Co-Authored-By: Claude Sonnet 4.6 --- .../channel/forward/SelectionActionBar.tsx | 36 +++++ .../__tests__/SelectionActionBar.test.tsx | 111 ++++++++++++++++ .../forward/__tests__/eligibility.test.ts | 125 ++++++++++++++++++ .../components/channel/forward/eligibility.ts | 32 +++++ 4 files changed, 304 insertions(+) create mode 100644 apps/client/src/components/channel/forward/SelectionActionBar.tsx create mode 100644 apps/client/src/components/channel/forward/__tests__/SelectionActionBar.test.tsx create mode 100644 apps/client/src/components/channel/forward/__tests__/eligibility.test.ts create mode 100644 apps/client/src/components/channel/forward/eligibility.ts diff --git a/apps/client/src/components/channel/forward/SelectionActionBar.tsx b/apps/client/src/components/channel/forward/SelectionActionBar.tsx new file mode 100644 index 00000000..5496d23b --- /dev/null +++ b/apps/client/src/components/channel/forward/SelectionActionBar.tsx @@ -0,0 +1,36 @@ +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { useForwardSelectionStore } from "@/stores/useForwardSelectionStore"; + +interface Props { + onForward: () => void; +} + +export function SelectionActionBar({ onForward }: Props) { + const { t } = useTranslation("channel"); + const active = useForwardSelectionStore((s) => s.active); + const selectedSize = useForwardSelectionStore((s) => s.selectedIds.size); + const exit = useForwardSelectionStore((s) => s.exit); + + if (!active) return null; + + return ( +
+ + {t("forward.selection.bar", { count: selectedSize })} + +
+ + +
+
+ ); +} diff --git a/apps/client/src/components/channel/forward/__tests__/SelectionActionBar.test.tsx b/apps/client/src/components/channel/forward/__tests__/SelectionActionBar.test.tsx new file mode 100644 index 00000000..21f415e4 --- /dev/null +++ b/apps/client/src/components/channel/forward/__tests__/SelectionActionBar.test.tsx @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; + +// ── Mocks ────────────────────────────────────────────────────────────────── + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (k: string, params?: { count?: number }) => + params?.count !== undefined ? `${k}:${params.count}` : k, + }), +})); + +// Mock Button to pass through all props cleanly +vi.mock("@/components/ui/button", () => ({ + Button: ({ + children, + onClick, + disabled, + variant, + }: { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + variant?: string; + }) => ( + + ), +})); + +import { SelectionActionBar } from "../SelectionActionBar"; +import { useForwardSelectionStore } from "@/stores/useForwardSelectionStore"; + +// ── helpers ────────────────────────────────────────────────────────────────── + +function resetStore() { + useForwardSelectionStore.getState().exit(); +} + +// ── tests ───────────────────────────────────────────────────────────────────── + +beforeEach(() => { + resetStore(); +}); + +describe("SelectionActionBar", () => { + describe("inactive state", () => { + it("renders nothing when active is false", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + }); + + describe("active state", () => { + beforeEach(() => { + useForwardSelectionStore.getState().enter("ch-1"); + }); + + it("renders the bar with count label and buttons when active", () => { + render(); + + expect( + screen.getByRole("region", { name: "Selection actions" }), + ).toBeInTheDocument(); + expect(screen.getByText("forward.selection.bar:0")).toBeInTheDocument(); + expect(screen.getByText("forward.selection.cancel")).toBeInTheDocument(); + expect(screen.getByText("forward.toolbar.forward")).toBeInTheDocument(); + }); + + it("Cancel button calls exit (store becomes inactive)", () => { + render(); + + const cancelBtn = screen.getByText("forward.selection.cancel"); + fireEvent.click(cancelBtn); + + expect(useForwardSelectionStore.getState().active).toBe(false); + }); + + it("Forward button is disabled when nothing is selected", () => { + render(); + + const forwardBtn = screen.getByText("forward.toolbar.forward"); + expect(forwardBtn).toBeDisabled(); + }); + + it("Forward button is enabled when count > 0 and calls onForward", () => { + useForwardSelectionStore.getState().toggle("msg-1"); + useForwardSelectionStore.getState().toggle("msg-2"); + + const onForward = vi.fn(); + render(); + + const forwardBtn = screen.getByText("forward.toolbar.forward"); + expect(forwardBtn).not.toBeDisabled(); + + fireEvent.click(forwardBtn); + expect(onForward).toHaveBeenCalledOnce(); + }); + + it("displays the correct count in the label", () => { + useForwardSelectionStore.getState().toggle("msg-1"); + useForwardSelectionStore.getState().toggle("msg-2"); + useForwardSelectionStore.getState().toggle("msg-3"); + + render(); + + expect(screen.getByText("forward.selection.bar:3")).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/client/src/components/channel/forward/__tests__/eligibility.test.ts b/apps/client/src/components/channel/forward/__tests__/eligibility.test.ts new file mode 100644 index 00000000..c844f7b6 --- /dev/null +++ b/apps/client/src/components/channel/forward/__tests__/eligibility.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from "vitest"; +import type { Message } from "@/types/im"; +import { isForwardable, computeForwardableRange } from "../eligibility"; + +// ── helpers ────────────────────────────────────────────────────────────────── + +function makeMsg(id: string, overrides: Partial = {}): Message { + return { + id, + channelId: "ch-1", + senderId: "u-1", + content: "hello", + type: "text", + isPinned: false, + isEdited: false, + isDeleted: false, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + +// ── isForwardable ───────────────────────────────────────────────────────────── + +describe("isForwardable", () => { + describe("allowed types return true", () => { + it.each(["text", "long_text", "file", "image", "forward"] as const)( + "returns true for type=%s", + (type) => { + expect(isForwardable(makeMsg("m1", { type }))).toBe(true); + }, + ); + }); + + describe("disallowed types return false", () => { + it.each(["system", "tracking"] as const)( + "returns false for type=%s", + (type) => { + expect(isForwardable(makeMsg("m1", { type }))).toBe(false); + }, + ); + }); + + it("returns false when isDeleted is true", () => { + expect(isForwardable(makeMsg("m1", { isDeleted: true }))).toBe(false); + }); + + it("returns false when metadata.streaming === true", () => { + expect( + isForwardable(makeMsg("m1", { metadata: { streaming: true } })), + ).toBe(false); + }); + + it("returns true when metadata.streaming is falsy (false)", () => { + expect( + isForwardable(makeMsg("m1", { metadata: { streaming: false } })), + ).toBe(true); + }); + + it("returns true when metadata is absent", () => { + expect(isForwardable(makeMsg("m1", { metadata: undefined }))).toBe(true); + }); + + it("isDeleted check takes precedence over type check", () => { + // even an allowed type is blocked when deleted + expect( + isForwardable(makeMsg("m1", { type: "text", isDeleted: true })), + ).toBe(false); + }); +}); + +// ── computeForwardableRange ─────────────────────────────────────────────────── + +describe("computeForwardableRange", () => { + const msgs = [ + makeMsg("a"), + makeMsg("b"), + makeMsg("c"), + makeMsg("d"), + makeMsg("e"), + ]; + + it("forward direction returns inclusive slice in original order", () => { + expect(computeForwardableRange(msgs, "b", "d")).toEqual(["b", "c", "d"]); + }); + + it("reverse direction returns inclusive slice in original order", () => { + expect(computeForwardableRange(msgs, "d", "b")).toEqual(["b", "c", "d"]); + }); + + it("filters out ineligible messages within the range", () => { + const withDeleted = [ + makeMsg("a"), + makeMsg("b", { isDeleted: true }), + makeMsg("c"), + makeMsg("d"), + ]; + expect(computeForwardableRange(withDeleted, "a", "d")).toEqual([ + "a", + "c", + "d", + ]); + }); + + it("returns [] when fromId is not found", () => { + expect(computeForwardableRange(msgs, "MISSING", "d")).toEqual([]); + }); + + it("returns [] when toId is not found", () => { + expect(computeForwardableRange(msgs, "a", "MISSING")).toEqual([]); + }); + + it("returns [single] when fromId === toId", () => { + expect(computeForwardableRange(msgs, "c", "c")).toEqual(["c"]); + }); + + it("returns [] for empty message list", () => { + expect(computeForwardableRange([], "a", "b")).toEqual([]); + }); + + it("returns [] when single-id target is ineligible", () => { + const withSystem = [makeMsg("x", { type: "system" })]; + expect(computeForwardableRange(withSystem, "x", "x")).toEqual([]); + }); +}); diff --git a/apps/client/src/components/channel/forward/eligibility.ts b/apps/client/src/components/channel/forward/eligibility.ts new file mode 100644 index 00000000..be76c6dd --- /dev/null +++ b/apps/client/src/components/channel/forward/eligibility.ts @@ -0,0 +1,32 @@ +import type { Message } from "@/types/im"; + +const ALLOWED_TYPES: ReadonlySet = new Set([ + "text", + "long_text", + "file", + "image", + "forward", +]); + +export function isForwardable(message: Message): boolean { + if (message.isDeleted) return false; + if (!ALLOWED_TYPES.has(message.type)) return false; + const meta = message.metadata as Record | undefined; + if (meta?.streaming === true) return false; + return true; +} + +export function computeForwardableRange( + visibleMessages: Message[], + fromId: string, + toId: string, +): string[] { + const fromIdx = visibleMessages.findIndex((m) => m.id === fromId); + const toIdx = visibleMessages.findIndex((m) => m.id === toId); + if (fromIdx === -1 || toIdx === -1) return []; + const [lo, hi] = fromIdx <= toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx]; + return visibleMessages + .slice(lo, hi + 1) + .filter(isForwardable) + .map((m) => m.id); +} From be3aaa43e8b4b049379cb5b0313cc5d89e08b229 Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 2 May 2026 17:27:54 +0800 Subject: [PATCH 16/23] feat(client): integrate selection mode into MessageList and MessageItem 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 --- .../src/components/channel/MessageItem.tsx | 94 ++++++++++++- .../src/components/channel/MessageList.tsx | 60 ++++++++ .../channel/__tests__/MessageList.test.tsx | 130 ++++++++++++++++++ 3 files changed, 281 insertions(+), 3 deletions(-) diff --git a/apps/client/src/components/channel/MessageItem.tsx b/apps/client/src/components/channel/MessageItem.tsx index 2b547b91..a80789b8 100644 --- a/apps/client/src/components/channel/MessageItem.tsx +++ b/apps/client/src/components/channel/MessageItem.tsx @@ -1,6 +1,9 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Loader2, AlertCircle, Plus, RotateCcw, Tags, X } from "lucide-react"; +import { toast } from "sonner"; +import { useForwardSelectionStore } from "@/stores/useForwardSelectionStore"; +import { isForwardable, computeForwardableRange } from "./forward/eligibility"; import { UserAvatar } from "@/components/ui/user-avatar"; import { MessageContent } from "./MessageContent"; import { MessageAttachments } from "./MessageAttachments"; @@ -86,6 +89,11 @@ export interface MessageItemProps { * the hover toolbar's Properties button is hidden. */ supportsProperties?: boolean; + /** + * All messages visible in the current list — used for shift-click range + * selection. Optional: when absent the range feature is skipped. + */ + visibleMessages?: Message[]; } function getThinkingMetadata( @@ -121,10 +129,53 @@ export function MessageItem({ onEditSave, onEditCancel, supportsProperties = false, + visibleMessages, }: MessageItemProps) { - const { t } = useTranslation(["thread", "message"]); + const { t } = useTranslation(["thread", "message", "channel"]); const thinkingMetadata = getThinkingMetadata(message.metadata); const [isHovered, setIsHovered] = useState(false); + + // Selection mode (message forwarding) + const selectionActive = useForwardSelectionStore((s) => s.active); + const selectionChannelId = useForwardSelectionStore((s) => s.channelId); + const selectionToggle = useForwardSelectionStore((s) => s.toggle); + const selectionAddRange = useForwardSelectionStore((s) => s.addRange); + const isSelectedFn = useForwardSelectionStore((s) => s.isSelected); + + const inSelectionMode = + selectionActive && selectionChannelId === message.channelId; + const isEligible = isForwardable(message); + const isSelected = inSelectionMode && isSelectedFn(message.id); + const lastAnchorRef = useRef(null); + + const toggleSelection = useCallback( + (shiftKey: boolean) => { + if (!isEligible) return; + if (shiftKey && lastAnchorRef.current && visibleMessages) { + const range = computeForwardableRange( + visibleMessages, + lastAnchorRef.current, + message.id, + ); + const added = selectionAddRange(range); + if (added < range.length) { + toast.error(t("channel:forward.tooManySelected")); + } + } else { + const ok = selectionToggle(message.id); + if (!ok) toast.error(t("channel:forward.tooManySelected")); + lastAnchorRef.current = message.id; + } + }, + [ + isEligible, + visibleMessages, + message.id, + selectionAddRange, + selectionToggle, + t, + ], + ); const isSystemMessage = message.type === "system"; const isOwnMessage = currentUserId === message.senderId; const isSending = message.sendStatus === "sending"; @@ -267,7 +318,8 @@ export function MessageItem({ const hasContent = Boolean(message.content?.trim()); const hasAttachments = message.attachments && message.attachments.length > 0; - const showToolbar = isHovered && !isSending && !isFailed && !isRootMessage; + const showToolbar = + isHovered && !isSending && !isFailed && !isRootMessage && !inSelectionMode; const hasReactions = message.reactions && message.reactions.length > 0; // Hover-toolbar Tags button is the entry point for creating the first // property too, so it must show even before any definitions exist. @@ -341,10 +393,40 @@ export function MessageItem({ "bg-warning/20 dark:bg-warning/30 ring-2 ring-warning dark:ring-warning", isSending && "opacity-70", isFailed && "bg-destructive/10 dark:bg-destructive/10", + inSelectionMode && "cursor-pointer", + inSelectionMode && isSelected && "bg-primary/10 dark:bg-primary/10", )} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} + onClick={ + inSelectionMode + ? (e) => { + e.preventDefault(); + e.stopPropagation(); + toggleSelection(e.shiftKey); + } + : undefined + } > + {inSelectionMode && ( +
+ { + const native = e.nativeEvent as MouseEvent; + toggleSelection(native.shiftKey ?? false); + }} + onClick={(e) => e.stopPropagation()} + className="mr-2 cursor-pointer disabled:cursor-not-allowed" + /> +
+ )} {showToolbar && onAddReaction && ( s.active); + const selectionChannelId = useForwardSelectionStore((s) => s.channelId); + const selectionIds = useForwardSelectionStore((s) => s.selectedIds); + const selectionExit = useForwardSelectionStore((s) => s.exit); + const [forwardOpen, setForwardOpen] = useState(false); + + // Esc key exits selection mode + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === "Escape" && selectionActive) selectionExit(); + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [selectionActive, selectionExit]); + + // Exit selection mode when switching channels + useEffect(() => { + if (selectionActive && selectionChannelId !== channelId) { + selectionExit(); + } + }, [channelId, selectionActive, selectionChannelId, selectionExit]); + const scrollStore = useChannelScrollStore(); const scrollState = scrollStore.getChannelState(channelId); const showIndicator = scrollStore.shouldShowIndicator(channelId); @@ -195,6 +221,20 @@ export function MessageList({ [messages], ); const chronoMessages = useMemo(() => pairToolEvents(rawChrono), [rawChrono]); + + // Derive selected Message objects from the current selection store + const messagesById = useMemo( + () => new Map(chronoMessages.map((m) => [m.id, m])), + [chronoMessages], + ); + const selectedMessages = useMemo( + () => + Array.from(selectionIds) + .map((id) => messagesById.get(id)) + .filter((m): m is Message => !!m), + [selectionIds, messagesById], + ); + const listData = useMemo(() => { const items: ChannelListItem[] = chronoMessages.map((message) => ({ type: "message", @@ -659,6 +699,7 @@ export function MessageList({ isRootMessage={true} isHighlighted={isHighlighted} supportsProperties={supportsProperties} + visibleMessages={chronoMessages} />
{foldedRoundSummary} @@ -687,6 +728,7 @@ export function MessageList({ onEditStart={handleEditStart} onEditSave={handleEditSave} onEditCancel={handleEditCancel} + visibleMessages={chronoMessages} />
{foldedRoundSummary} @@ -716,6 +758,7 @@ export function MessageList({ handleEditStart, handleEditSave, handleEditCancel, + chronoMessages, ], ); @@ -795,6 +838,20 @@ export function MessageList({ onClick={handleJumpToLatest} /> )} + + setForwardOpen(true)} /> + {forwardOpen && selectedMessages.length > 0 && ( + { + setForwardOpen(false); + selectionExit(); + }} + /> + )} ); } @@ -816,6 +873,7 @@ function ChannelMessageItem({ onEditStart, onEditSave, onEditCancel, + visibleMessages, }: { message: Message; prevMessage?: Message; @@ -835,6 +893,7 @@ function ChannelMessageItem({ payload: { content: string; contentAst?: Record }, ) => Promise; onEditCancel: () => void; + visibleMessages?: Message[]; }) { const openThread = useThreadStore((state) => state.openThread); const deleteMessage = useDeleteMessage(); @@ -927,6 +986,7 @@ function ChannelMessageItem({ onEditSave={(payload) => onEditSave(message.id, payload)} onEditCancel={onEditCancel} supportsProperties={supportsProperties} + visibleMessages={visibleMessages} /> ({ useShallow: (x: T) => x, })); +// SelectionActionBar: mirrors the real component's active guard so tests can +// assert both the "not rendered" and "rendered" states. +vi.mock("../forward/SelectionActionBar", async () => { + const { useForwardSelectionStore: useStore } = await vi.importActual< + typeof import("@/stores/useForwardSelectionStore") + >("@/stores/useForwardSelectionStore"); + return { + SelectionActionBar: ({ onForward }: { onForward: () => void }) => { + const active = useStore((s) => s.active); + if (!active) return null; + return ( +
+ +
+ ); + }, + }; +}); + +// ForwardDialog: simple stub — just expose that it rendered. +vi.mock("../forward/ForwardDialog", () => ({ + ForwardDialog: ({ + open, + onSuccess, + }: { + open: boolean; + onOpenChange: (open: boolean) => void; + sourceChannelId: string; + sourceMessages: unknown[]; + onSuccess?: () => void; + }) => + open ? ( +
+ +
+ ) : null, +})); + // --------------------------------------------------------------------------- // After mocks: import the component under test. // --------------------------------------------------------------------------- import { MessageList } from "../MessageList"; +import { useForwardSelectionStore } from "@/stores/useForwardSelectionStore"; // --------------------------------------------------------------------------- // Helpers @@ -340,6 +379,8 @@ function renderList( beforeEach(() => { vi.clearAllMocks(); mockChannelStreams.current = []; + // Reset forward selection store state between tests + useForwardSelectionStore.getState().exit(); }); describe("MessageList — round auto-fold", () => { @@ -820,3 +861,92 @@ describe("MessageList — round auto-fold", () => { }); }); }); + +// --------------------------------------------------------------------------- +// Selection mode tests +// --------------------------------------------------------------------------- + +describe("MessageList — selection mode", () => { + it("SelectionActionBar is not rendered when selection mode is inactive", () => { + const chrono = [makeMessage("m1"), makeMessage("m2")]; + renderList(chrono); + + // Store is inactive by default — bar should not appear + expect( + screen.queryByTestId("selection-action-bar"), + ).not.toBeInTheDocument(); + }); + + it("SelectionActionBar renders when selection mode is active for the current channel", () => { + useForwardSelectionStore.getState().enter("ch-1"); + + const chrono = [makeMessage("m1"), makeMessage("m2")]; + renderList(chrono); + + expect(screen.getByTestId("selection-action-bar")).toBeInTheDocument(); + }); + + it("Esc keypress exits selection mode", () => { + useForwardSelectionStore.getState().enter("ch-1"); + + const chrono = [makeMessage("m1")]; + renderList(chrono); + + // Confirm bar is visible first + expect(screen.getByTestId("selection-action-bar")).toBeInTheDocument(); + + // Fire Escape key on window + fireEvent.keyDown(window, { key: "Escape" }); + + expect(useForwardSelectionStore.getState().active).toBe(false); + }); + + it("re-rendering with a different channelId when selection was active calls exit", () => { + useForwardSelectionStore.getState().enter("ch-1"); + + const chrono = [makeMessage("m1")]; + const { rerender } = renderList(chrono); + + expect(useForwardSelectionStore.getState().active).toBe(true); + + // Switch to a different channel + rerender( + + + , + ); + + expect(useForwardSelectionStore.getState().active).toBe(false); + }); + + it("clicking Forward button in SelectionActionBar opens the ForwardDialog when messages are selected", () => { + useForwardSelectionStore.getState().enter("ch-1"); + // Toggle a message so selectedMessages is non-empty + useForwardSelectionStore.getState().toggle("m1"); + + const chrono = [makeMessage("m1"), makeMessage("m2")]; + renderList(chrono); + + expect(screen.queryByTestId("forward-dialog")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText("Forward")); + + expect(screen.getByTestId("forward-dialog")).toBeInTheDocument(); + }); + + it("onSuccess callback from ForwardDialog closes the dialog and exits selection mode", () => { + useForwardSelectionStore.getState().enter("ch-1"); + useForwardSelectionStore.getState().toggle("m1"); + + const chrono = [makeMessage("m1")]; + renderList(chrono); + + fireEvent.click(screen.getByText("Forward")); + expect(screen.getByTestId("forward-dialog")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("Confirm Forward")); + + expect(screen.queryByTestId("forward-dialog")).not.toBeInTheDocument(); + expect(useForwardSelectionStore.getState().active).toBe(false); + }); +}); From b1a8bd6913a457d1f26dd1f3cbeaffcf77118a9c Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 2 May 2026 17:29:50 +0800 Subject: [PATCH 17/23] fix(client): satisfy i18next strict typing on dynamic forward error key --- apps/client/src/components/channel/forward/ForwardDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/client/src/components/channel/forward/ForwardDialog.tsx b/apps/client/src/components/channel/forward/ForwardDialog.tsx index 15dbc9dc..21031fda 100644 --- a/apps/client/src/components/channel/forward/ForwardDialog.tsx +++ b/apps/client/src/components/channel/forward/ForwardDialog.tsx @@ -64,7 +64,7 @@ export function ForwardDialog({ onError: (err: unknown) => { const code = extractErrorCode(err); const key = ERROR_TO_KEY[code] ?? "forward.error.notAllowed"; - toast.error(t(key)); + toast.error(t(key as never)); }, }); From 736fcda9699822a9bd99243c65819ab6cd89bc41 Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 2 May 2026 17:35:24 +0800 Subject: [PATCH 18/23] feat(client): wire forward+select into hover toolbar and context menu 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 --- .../components/channel/MessageContextMenu.tsx | 36 +++- .../channel/MessageHoverToolbar.tsx | 49 +++++- .../src/components/channel/MessageItem.tsx | 52 ++++-- .../__tests__/MessageContextMenu.test.tsx | 166 ++++++++++++++++++ .../__tests__/MessageHoverToolbar.test.tsx | 126 +++++++++++++ 5 files changed, 414 insertions(+), 15 deletions(-) create mode 100644 apps/client/src/components/channel/__tests__/MessageContextMenu.test.tsx create mode 100644 apps/client/src/components/channel/__tests__/MessageHoverToolbar.test.tsx diff --git a/apps/client/src/components/channel/MessageContextMenu.tsx b/apps/client/src/components/channel/MessageContextMenu.tsx index e64ae3ad..b3fb78ce 100644 --- a/apps/client/src/components/channel/MessageContextMenu.tsx +++ b/apps/client/src/components/channel/MessageContextMenu.tsx @@ -7,7 +7,16 @@ import { ContextMenuShortcut, ContextMenuTrigger, } from "@/components/ui/context-menu"; -import { MessageSquare, Link, Copy, Pin, Trash2, Pencil } from "lucide-react"; +import { + MessageSquare, + Link, + Copy, + Pin, + Trash2, + Pencil, + Forward, + CheckSquare, +} from "lucide-react"; import type { Message } from "@/types/im"; interface MessageContextMenuProps { @@ -21,6 +30,12 @@ interface MessageContextMenuProps { onPin?: () => void; onEdit?: () => void; onDelete?: () => void; + /** Called when Forward menu item is clicked. Only shown when forwardable is true. */ + onForward?: () => void; + /** Called when Select menu item is clicked. Only shown when forwardable is true. */ + onSelect?: () => void; + /** Controls visibility of Forward + Select menu items. */ + forwardable?: boolean; } export function MessageContextMenu({ @@ -34,8 +49,12 @@ export function MessageContextMenu({ onPin, onEdit, onDelete, + onForward, + onSelect, + forwardable, }: MessageContextMenuProps) { const { t } = useTranslation("message"); + const { t: tChannel } = useTranslation("channel"); const handleCopyMessage = () => { if (message.content) { @@ -64,6 +83,21 @@ export function MessageContextMenu({ )} + {/* Forward / Select actions */} + {forwardable && onForward && ( + + + {tChannel("forward.contextMenu.forward")} + F + + )} + {forwardable && onSelect && ( + + + {tChannel("forward.contextMenu.select")} + + )} + {/* Copy actions */} diff --git a/apps/client/src/components/channel/MessageHoverToolbar.tsx b/apps/client/src/components/channel/MessageHoverToolbar.tsx index 5955d118..b40cda9e 100644 --- a/apps/client/src/components/channel/MessageHoverToolbar.tsx +++ b/apps/client/src/components/channel/MessageHoverToolbar.tsx @@ -1,5 +1,5 @@ import { useState, type ReactNode } from "react"; -import { MessageSquare, SmilePlus } from "lucide-react"; +import { MessageSquare, SmilePlus, Forward, CheckSquare } from "lucide-react"; import { Popover, PopoverTrigger, @@ -24,12 +24,21 @@ interface MessageHoverToolbarProps { * wrapping a Tags button. When omitted, nothing is rendered for properties. */ propertiesSlot?: ReactNode; + /** Called when Forward icon is clicked. Only rendered when forwardable is true. */ + onForward?: () => void; + /** Called when Select icon is clicked. Only rendered when forwardable is true. */ + onSelect?: () => void; + /** Controls visibility of Forward + Select buttons. */ + forwardable?: boolean; } export function MessageHoverToolbar({ onReaction, onReplyInThread, propertiesSlot, + onForward, + onSelect, + forwardable, }: MessageHoverToolbarProps) { const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); @@ -111,6 +120,44 @@ export function MessageHoverToolbar({ )} + + {forwardable && (onForward || onSelect) && ( +
+ )} + + {forwardable && onForward && ( + + + + + + Forward + + + )} + + {forwardable && onSelect && ( + + + + + + Select + + + )}
); diff --git a/apps/client/src/components/channel/MessageItem.tsx b/apps/client/src/components/channel/MessageItem.tsx index a80789b8..3a42c451 100644 --- a/apps/client/src/components/channel/MessageItem.tsx +++ b/apps/client/src/components/channel/MessageItem.tsx @@ -4,6 +4,7 @@ import { Loader2, AlertCircle, Plus, RotateCcw, Tags, X } from "lucide-react"; import { toast } from "sonner"; import { useForwardSelectionStore } from "@/stores/useForwardSelectionStore"; import { isForwardable, computeForwardableRange } from "./forward/eligibility"; +import { ForwardDialog } from "./forward/ForwardDialog"; import { UserAvatar } from "@/components/ui/user-avatar"; import { MessageContent } from "./MessageContent"; import { MessageAttachments } from "./MessageAttachments"; @@ -148,6 +149,15 @@ export function MessageItem({ const isSelected = inSelectionMode && isSelectedFn(message.id); const lastAnchorRef = useRef(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; @@ -432,6 +442,9 @@ export function MessageItem({ onReaction={handleReactionToggle} onReplyInThread={onReplyInThread} propertiesSlot={propertiesHoverSlot} + forwardable={isEligible} + onForward={handleForward} + onSelect={handleSelect} /> )} - {content} - + <> + + {content} + + {forwardOpen && ( + + )} + ); } diff --git a/apps/client/src/components/channel/__tests__/MessageContextMenu.test.tsx b/apps/client/src/components/channel/__tests__/MessageContextMenu.test.tsx new file mode 100644 index 00000000..dc76e7e7 --- /dev/null +++ b/apps/client/src/components/channel/__tests__/MessageContextMenu.test.tsx @@ -0,0 +1,166 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import type { Message } from "@/types/im"; +import { MessageContextMenu } from "../MessageContextMenu"; + +vi.mock("react-i18next", () => ({ + useTranslation: (_ns?: string) => ({ t: (key: string) => key }), +})); + +// Radix ContextMenu portals to document.body. We need to open it before we can +// query menu items. Use `fireEvent.contextMenu` on the trigger element. + +function makeMessage(overrides: Partial = {}): Message { + return { + id: "msg-1", + channelId: "ch-1", + senderId: "user-1", + content: "hello", + type: "text", + isPinned: false, + isEdited: false, + isDeleted: false, + createdAt: "2026-04-25T12:00:00Z", + updatedAt: "2026-04-25T12:00:00Z", + ...overrides, + }; +} + +function renderMenu( + props: Partial[0]> = {}, +) { + const defaultProps = { + message: makeMessage(), + isOwnMessage: false, + children:
Message
, + }; + return render(); +} + +function openMenu() { + const trigger = screen.getByTestId("trigger"); + fireEvent.contextMenu(trigger); +} + +// --------------------------------------------------------------------------- +// Forward + Select visibility +// --------------------------------------------------------------------------- + +describe("MessageContextMenu — forward + select wiring", () => { + it("renders Forward and Select items when forwardable=true and handlers provided", () => { + renderMenu({ forwardable: true, onForward: vi.fn(), onSelect: vi.fn() }); + openMenu(); + + // Radix portals to document.body — use queryAllBy scoped to body + expect( + screen.getByRole("menuitem", { name: /forward\.contextMenu\.forward/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("menuitem", { name: /forward\.contextMenu\.select/i }), + ).toBeInTheDocument(); + }); + + it("hides Forward and Select items when forwardable=false", () => { + renderMenu({ forwardable: false, onForward: vi.fn(), onSelect: vi.fn() }); + openMenu(); + + expect( + screen.queryByRole("menuitem", { + name: /forward\.contextMenu\.forward/i, + }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("menuitem", { name: /forward\.contextMenu\.select/i }), + ).not.toBeInTheDocument(); + }); + + it("hides Forward item when handler not provided even with forwardable=true", () => { + renderMenu({ forwardable: true, onForward: undefined, onSelect: vi.fn() }); + openMenu(); + + expect( + screen.queryByRole("menuitem", { + name: /forward\.contextMenu\.forward/i, + }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("menuitem", { name: /forward\.contextMenu\.select/i }), + ).toBeInTheDocument(); + }); + + it("hides Select item when handler not provided even with forwardable=true", () => { + renderMenu({ forwardable: true, onForward: vi.fn(), onSelect: undefined }); + openMenu(); + + expect( + screen.getByRole("menuitem", { name: /forward\.contextMenu\.forward/i }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("menuitem", { name: /forward\.contextMenu\.select/i }), + ).not.toBeInTheDocument(); + }); + + it("hides both items when forwardable is undefined (default)", () => { + renderMenu({ onForward: vi.fn(), onSelect: vi.fn() }); + openMenu(); + + expect( + screen.queryByRole("menuitem", { + name: /forward\.contextMenu\.forward/i, + }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("menuitem", { name: /forward\.contextMenu\.select/i }), + ).not.toBeInTheDocument(); + }); + + // --------------------------------------------------------------------------- + // Click handlers + // --------------------------------------------------------------------------- + + it("calls onForward when Forward item is clicked", () => { + const onForward = vi.fn(); + renderMenu({ forwardable: true, onForward, onSelect: vi.fn() }); + openMenu(); + + fireEvent.click( + screen.getByRole("menuitem", { name: /forward\.contextMenu\.forward/i }), + ); + + expect(onForward).toHaveBeenCalledTimes(1); + }); + + it("calls onSelect when Select item is clicked", () => { + const onSelect = vi.fn(); + renderMenu({ forwardable: true, onForward: vi.fn(), onSelect }); + openMenu(); + + fireEvent.click( + screen.getByRole("menuitem", { name: /forward\.contextMenu\.select/i }), + ); + + expect(onSelect).toHaveBeenCalledTimes(1); + }); + + // --------------------------------------------------------------------------- + // Existing items are not broken + // --------------------------------------------------------------------------- + + it("still renders Copy Link item", () => { + renderMenu(); + openMenu(); + + expect( + screen.getByRole("menuitem", { name: /copyLink/i }), + ).toBeInTheDocument(); + }); + + it("still renders Reply in thread item when handler provided", () => { + renderMenu({ onReplyInThread: vi.fn() }); + openMenu(); + + expect( + screen.getByRole("menuitem", { name: /replyInThread/i }), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/client/src/components/channel/__tests__/MessageHoverToolbar.test.tsx b/apps/client/src/components/channel/__tests__/MessageHoverToolbar.test.tsx new file mode 100644 index 00000000..0a418318 --- /dev/null +++ b/apps/client/src/components/channel/__tests__/MessageHoverToolbar.test.tsx @@ -0,0 +1,126 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { MessageHoverToolbar } from "../MessageHoverToolbar"; + +// Stub EmojiPicker to avoid pulling in heavy dependencies +vi.mock("../editor/EmojiPicker", () => ({ + EmojiPicker: ({ onSelect }: { onSelect: (e: string) => void }) => ( + + ), +})); + +function renderToolbar( + props: Partial[0]> = {}, +) { + return render( + + + , + ); +} + +// --------------------------------------------------------------------------- +// Forward + Select visibility +// --------------------------------------------------------------------------- + +describe("MessageHoverToolbar — forward + select wiring", () => { + it("renders Forward and Select buttons when forwardable=true and handlers provided", () => { + renderToolbar({ + forwardable: true, + onForward: vi.fn(), + onSelect: vi.fn(), + }); + + expect(screen.getByRole("button", { name: "Forward" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Select" })).toBeInTheDocument(); + }); + + it("hides Forward and Select buttons when forwardable=false", () => { + renderToolbar({ + forwardable: false, + onForward: vi.fn(), + onSelect: vi.fn(), + }); + + expect( + screen.queryByRole("button", { name: "Forward" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Select" }), + ).not.toBeInTheDocument(); + }); + + it("hides Forward button when handler is not provided even with forwardable=true", () => { + renderToolbar({ + forwardable: true, + onForward: undefined, + onSelect: vi.fn(), + }); + + expect( + screen.queryByRole("button", { name: "Forward" }), + ).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Select" })).toBeInTheDocument(); + }); + + it("hides Select button when handler is not provided even with forwardable=true", () => { + renderToolbar({ + forwardable: true, + onForward: vi.fn(), + onSelect: undefined, + }); + + expect(screen.getByRole("button", { name: "Forward" })).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Select" }), + ).not.toBeInTheDocument(); + }); + + it("hides both buttons when forwardable is undefined (default)", () => { + renderToolbar({ + onForward: vi.fn(), + onSelect: vi.fn(), + }); + + expect( + screen.queryByRole("button", { name: "Forward" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Select" }), + ).not.toBeInTheDocument(); + }); + + // --------------------------------------------------------------------------- + // Click handlers + // --------------------------------------------------------------------------- + + it("calls onForward when Forward button is clicked", () => { + const onForward = vi.fn(); + renderToolbar({ forwardable: true, onForward, onSelect: vi.fn() }); + + fireEvent.click(screen.getByRole("button", { name: "Forward" })); + + expect(onForward).toHaveBeenCalledTimes(1); + }); + + it("calls onSelect when Select button is clicked", () => { + const onSelect = vi.fn(); + renderToolbar({ forwardable: true, onForward: vi.fn(), onSelect }); + + fireEvent.click(screen.getByRole("button", { name: "Select" })); + + expect(onSelect).toHaveBeenCalledTimes(1); + }); + + // --------------------------------------------------------------------------- + // Existing toolbar buttons are not broken + // --------------------------------------------------------------------------- + + it("still renders quick emoji buttons", () => { + renderToolbar(); + + // QUICK_EMOJIS = ["👀", "👍", "🙌", "✅"] + expect(screen.getByRole("button", { name: "👀" })).toBeInTheDocument(); + }); +}); From e8caeab9c2eaaf8a4a8814c48db9bf801f7e548e Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 2 May 2026 17:38:00 +0800 Subject: [PATCH 19/23] feat(client): dispatch MessageContent to ForwardedMessageCard for type=forward --- .../src/components/channel/MessageContent.tsx | 7 ++ .../__tests__/MessageContent.forward.test.tsx | 70 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 apps/client/src/components/channel/__tests__/MessageContent.forward.test.tsx diff --git a/apps/client/src/components/channel/MessageContent.tsx b/apps/client/src/components/channel/MessageContent.tsx index 26b866df..ddd251c8 100644 --- a/apps/client/src/components/channel/MessageContent.tsx +++ b/apps/client/src/components/channel/MessageContent.tsx @@ -22,6 +22,7 @@ import { UserProfileCard } from "./UserProfileCard"; import { CodeBlock } from "./CodeBlock"; import { ImagePreviewDialog } from "./ImagePreviewDialog"; import { LongTextCollapse } from "./LongTextCollapse"; +import { ForwardedMessageCard } from "./forward/ForwardedMessageCard"; import { useCreateDirectChannel } from "@/hooks/useChannels"; import { SelectionCopyPopup } from "./SelectionCopyPopup"; import { AstRenderer } from "./AstRenderer"; @@ -396,6 +397,12 @@ export function MessageContent({ className, message, }: MessageContentProps) { + // Forward-type messages bypass the normal content pipeline and render + // a quote/bundle card driven by the hydrated `message.forward` payload. + if (message?.type === "forward") { + return ; + } + // For long_text messages, reactively subscribe to the full-content cache. // enabled: false means this hook never initiates a fetch — LongTextCollapse // handles that. But it does subscribe to cache updates, so when the full diff --git a/apps/client/src/components/channel/__tests__/MessageContent.forward.test.tsx b/apps/client/src/components/channel/__tests__/MessageContent.forward.test.tsx new file mode 100644 index 00000000..fb5bcbca --- /dev/null +++ b/apps/client/src/components/channel/__tests__/MessageContent.forward.test.tsx @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { Message } from "@/types/im"; + +// Stub the forwarded card so this test doesn't drag in the entire forward +// rendering pipeline (router, query client, etc.). The integration of the +// real ForwardedMessageCard is covered by its own component test. +vi.mock("../forward/ForwardedMessageCard", () => ({ + ForwardedMessageCard: ({ message }: { message: Message }) => ( +
forward-{message.id}
+ ), +})); + +import { MessageContent } from "../MessageContent"; + +function renderWithProviders(ui: React.ReactNode) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render({ui}); +} + +const baseMessage = { + id: "m-1", + channelId: "ch-1", + senderId: "u-1", + content: "hello", + contentAst: null, + type: "text", + metadata: null, + isPinned: false, + isEdited: false, + isDeleted: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +} as unknown as Message; + +describe("MessageContent — forward dispatch", () => { + it("renders ForwardedMessageCard for forward-type messages", () => { + const fwdMessage = { + ...baseMessage, + id: "fwd-1", + type: "forward", + forward: { + kind: "single", + count: 1, + sourceChannelId: "ch-2", + sourceChannelName: "src", + truncated: false, + items: [], + }, + } as unknown as Message; + + renderWithProviders(); + expect(screen.getByTestId("forwarded-card")).toHaveTextContent( + "forward-fwd-1", + ); + }); + + it("does not render ForwardedMessageCard for non-forward messages", () => { + renderWithProviders(); + expect(screen.queryByTestId("forwarded-card")).toBeNull(); + }); + + it("does not render ForwardedMessageCard when message prop is omitted", () => { + renderWithProviders(); + expect(screen.queryByTestId("forwarded-card")).toBeNull(); + }); +}); From 4e849fce0a5f0b002e10377eafefc8d00f0e7e6b Mon Sep 17 00:00:00 2001 From: Winrey Date: Tue, 5 May 2026 07:02:44 +0800 Subject: [PATCH 20/23] docs: mark all message-forwarding tasks complete in .tasks.json --- ...026-05-02-message-forwarding.md.tasks.json | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/superpowers/plans/2026-05-02-message-forwarding.md.tasks.json b/docs/superpowers/plans/2026-05-02-message-forwarding.md.tasks.json index c627d17c..d5beddcb 100644 --- a/docs/superpowers/plans/2026-05-02-message-forwarding.md.tasks.json +++ b/docs/superpowers/plans/2026-05-02-message-forwarding.md.tasks.json @@ -1,17 +1,17 @@ { "planPath": "docs/superpowers/plans/2026-05-02-message-forwarding.md", - "lastUpdated": "2026-05-02T07:14:19Z", + "lastUpdated": "2026-05-02T09:38:10Z", "tasks": [ { "id": 0, "subject": "Task 0: i18n strings (en + zh-CN)", - "status": "pending", - "description": "**Goal:** Add forward.* keys to en + zh-CN locales.\n\n**Files:** apps/client/src/i18n/locales/{en,zh-CN}/channel.json\n\n**Verify:** node JSON.parse + pnpm --filter @team9/client lint\n\n```json:metadata\n{\"files\":[\"apps/client/src/i18n/locales/en/channel.json\",\"apps/client/src/i18n/locales/zh-CN/channel.json\"],\"verifyCommand\":\"pnpm --filter @team9/client lint\",\"acceptanceCriteria\":[\"all forward.* keys from spec §2.5 in both files\",\"JSON parses cleanly\"]}\n```" + "status": "completed", + "description": "**Goal:** Add forward.* keys to en + zh-CN locales.\n\n**Files:** apps/client/src/i18n/locales/{en,zh-CN}/channel.json\n\n**Verify:** node JSON.parse + pnpm --filter @team9/client lint\n\n```json:metadata\n{\"files\":[\"apps/client/src/i18n/locales/en/channel.json\",\"apps/client/src/i18n/locales/zh-CN/channel.json\"],\"verifyCommand\":\"pnpm --filter @team9/client lint\",\"acceptanceCriteria\":[\"all forward.* keys from spec \u00a72.5 in both files\",\"JSON parses cleanly\"]}\n```" }, { "id": 1, - "subject": "Task 1: DB schema — 'forward' enum + im_message_forwards table + migration", - "status": "pending", + "subject": "Task 1: DB schema \u2014 'forward' enum + im_message_forwards table + migration", + "status": "completed", "description": "**Goal:** Land the schema and migration for forward storage.\n\n**Files:** apps/server/libs/database/src/schemas/im/{messages.ts,message-forwards.ts,index.ts,message-forwards.spec.ts}; apps/server/libs/database/drizzle/_*.sql\n\n**Verify:** pnpm db:generate; pnpm db:migrate; pnpm --filter @team9/database test -- message-forwards\n\n```json:metadata\n{\"files\":[\"apps/server/libs/database/src/schemas/im/messages.ts\",\"apps/server/libs/database/src/schemas/im/message-forwards.ts\",\"apps/server/libs/database/src/schemas/im/index.ts\",\"apps/server/libs/database/src/schemas/im/message-forwards.spec.ts\"],\"verifyCommand\":\"pnpm --filter @team9/database test -- message-forwards\",\"acceptanceCriteria\":[\"messageTypeEnum includes 'forward'\",\"messageForwards table compiles with all columns + indexes\",\"db:generate produces single migration; db:migrate applies cleanly\",\"schema spec covers position ordering + cascade + set null + NOT NULL guard\"]}\n```" }, { @@ -22,35 +22,35 @@ }, { "id": 3, - "subject": "Task 3: ForwardsService — core business logic", + "subject": "Task 3: ForwardsService \u2014 core business logic", "status": "pending", "blockedBy": [1, 2], - "description": "**Goal:** Build ForwardsService.forward() + getForwardItems() + hydrate() with snapshot capture, validation, error mapping, re-forward support, and forward-row insert with rollback on failure.\n\n**Files:** apps/server/apps/gateway/src/im/messages/forwards/{types.ts,forwards.service.ts,forwards.service.spec.ts}\n\n**Verify:** pnpm --filter @team9/server test -- forwards.service --coverage\n\n```json:metadata\n{\"files\":[\"apps/server/apps/gateway/src/im/messages/forwards/types.ts\",\"apps/server/apps/gateway/src/im/messages/forwards/forwards.service.ts\",\"apps/server/apps/gateway/src/im/messages/forwards/forwards.service.spec.ts\"],\"verifyCommand\":\"pnpm --filter @team9/server test -- forwards.service --coverage\",\"acceptanceCriteria\":[\"happy paths: single text/image/long_text/forward + bundle of 5\",\"rejection paths match spec §4.5 error matrix\",\"snapshot truncation at 100k\",\"forward message has zero rows in im_message_attachments\",\"failed insert triggers softDelete + 500\",\"100% coverage on forwards.service.ts and types.ts\"]}\n```" + "description": "**Goal:** Build ForwardsService.forward() + getForwardItems() + hydrate() with snapshot capture, validation, error mapping, re-forward support, and forward-row insert with rollback on failure.\n\n**Files:** apps/server/apps/gateway/src/im/messages/forwards/{types.ts,forwards.service.ts,forwards.service.spec.ts}\n\n**Verify:** pnpm --filter @team9/server test -- forwards.service --coverage\n\n```json:metadata\n{\"files\":[\"apps/server/apps/gateway/src/im/messages/forwards/types.ts\",\"apps/server/apps/gateway/src/im/messages/forwards/forwards.service.ts\",\"apps/server/apps/gateway/src/im/messages/forwards/forwards.service.spec.ts\"],\"verifyCommand\":\"pnpm --filter @team9/server test -- forwards.service --coverage\",\"acceptanceCriteria\":[\"happy paths: single text/image/long_text/forward + bundle of 5\",\"rejection paths match spec \u00a74.5 error matrix\",\"snapshot truncation at 100k\",\"forward message has zero rows in im_message_attachments\",\"failed insert triggers softDelete + 500\",\"100% coverage on forwards.service.ts and types.ts\"]}\n```" }, { "id": 4, - "subject": "Task 4: ForwardsController — REST endpoints", + "subject": "Task 4: ForwardsController \u2014 REST endpoints", "status": "pending", "blockedBy": [3], "description": "**Goal:** POST /api/v1/im/channels/:id/forward + GET /api/v1/im/messages/:id/forward-items, registered in MessagesModule.\n\n**Files:** apps/server/apps/gateway/src/im/messages/forwards/{forwards.controller.ts,forwards.controller.spec.ts,dto/create-forward.dto.ts}; apps/server/apps/gateway/src/im/messages/messages.module.ts\n\n**Verify:** pnpm --filter @team9/server test -- forwards.controller\n\n```json:metadata\n{\"files\":[\"apps/server/apps/gateway/src/im/messages/forwards/forwards.controller.ts\",\"apps/server/apps/gateway/src/im/messages/forwards/forwards.controller.spec.ts\",\"apps/server/apps/gateway/src/im/messages/forwards/dto/create-forward.dto.ts\",\"apps/server/apps/gateway/src/im/messages/messages.module.ts\"],\"verifyCommand\":\"pnpm --filter @team9/server test -- forwards.controller\",\"acceptanceCriteria\":[\"POST validates DTO (UUID, length 1..100)\",\"GET returns full ForwardItemResponse[]\",\"both guarded by AuthGuard\",\"controller spec covers happy + delegation\"]}\n```" }, { "id": 5, - "subject": "Task 5: MessagesService — hydrate forward field on reads + reject PATCH", + "subject": "Task 5: MessagesService \u2014 hydrate forward field on reads + reject PATCH", "status": "pending", "blockedBy": [3], "description": "**Goal:** Attach forward payload via ForwardsService.hydratePayload on getMessageWithDetails + bulk paths; threading userId through; PATCH rejected on type=forward.\n\n**Files:** apps/server/apps/gateway/src/im/messages/messages.service.ts (+ .spec.ts), messages.controller.ts (+ .spec.ts)\n\n**Verify:** pnpm --filter @team9/server test -- messages.service messages.controller\n\n```json:metadata\n{\"files\":[\"apps/server/apps/gateway/src/im/messages/messages.service.ts\",\"apps/server/apps/gateway/src/im/messages/messages.service.spec.ts\",\"apps/server/apps/gateway/src/im/messages/messages.controller.ts\",\"apps/server/apps/gateway/src/im/messages/messages.controller.spec.ts\"],\"verifyCommand\":\"pnpm --filter @team9/server test -- messages.service messages.controller\",\"acceptanceCriteria\":[\"MessageResponse adds optional forward field\",\"hydration runs only for type=forward\",\"bulk fetch hydrates each forward in page\",\"PATCH on forward returns 400 forward.editDisabled\"]}\n```" }, { "id": 6, - "subject": "Task 6: Backend e2e — forward.e2e-spec.ts", + "subject": "Task 6: Backend e2e \u2014 forward.e2e-spec.ts", "status": "pending", "blockedBy": [4, 5], - "description": "**Goal:** End-to-end coverage per spec §9.2 (single + bundle + WS broadcast + re-forward + soft-delete + access errors).\n\n**Files:** apps/server/apps/gateway/test/forward.e2e-spec.ts\n\n**Verify:** pnpm --filter @team9/server test:e2e -- forward\n\n```json:metadata\n{\"files\":[\"apps/server/apps/gateway/test/forward.e2e-spec.ts\"],\"verifyCommand\":\"pnpm --filter @team9/server test:e2e -- forward\",\"acceptanceCriteria\":[\"happy bundle path with WS broadcast\",\"single image: 0 rows in im_message_attachments for forward msg\",\"403 on no read or no write access\",\"re-forward chain depth 1\",\"soft-deleted source still renders snapshot\",\">100 + mixed-channel rejections\"]}\n```" + "description": "**Goal:** End-to-end coverage per spec \u00a79.2 (single + bundle + WS broadcast + re-forward + soft-delete + access errors).\n\n**Files:** apps/server/apps/gateway/test/forward.e2e-spec.ts\n\n**Verify:** pnpm --filter @team9/server test:e2e -- forward\n\n```json:metadata\n{\"files\":[\"apps/server/apps/gateway/test/forward.e2e-spec.ts\"],\"verifyCommand\":\"pnpm --filter @team9/server test:e2e -- forward\",\"acceptanceCriteria\":[\"happy bundle path with WS broadcast\",\"single image: 0 rows in im_message_attachments for forward msg\",\"403 on no read or no write access\",\"re-forward chain depth 1\",\"soft-deleted source still renders snapshot\",\">100 + mixed-channel rejections\"]}\n```" }, { "id": 7, - "subject": "Task 7: Frontend — types, API client, selection store", + "subject": "Task 7: Frontend \u2014 types, API client, selection store", "status": "pending", "blockedBy": [4], "description": "**Goal:** MessageType union + ForwardPayload/ForwardItem types; forwardApi.create/getItems; useForwardSelectionStore with cap enforcement.\n\n**Files:** apps/client/src/types/im.ts; apps/client/src/services/api.ts; apps/client/src/stores/useForwardSelectionStore.ts (+ __tests__)\n\n**Verify:** pnpm --filter @team9/client test -- useForwardSelectionStore\n\n```json:metadata\n{\"files\":[\"apps/client/src/types/im.ts\",\"apps/client/src/services/api.ts\",\"apps/client/src/stores/useForwardSelectionStore.ts\",\"apps/client/src/stores/__tests__/useForwardSelectionStore.test.ts\"],\"verifyCommand\":\"pnpm --filter @team9/client test -- useForwardSelectionStore\",\"acceptanceCriteria\":[\"MessageType includes 'forward'; Message has optional forward\",\"forwardApi.create + getItems hit right URLs\",\"selection store: enter/exit/toggle/addRange/clear, cap=100, channel-switch clears\",\"100% coverage on store\"]}\n```" @@ -67,26 +67,26 @@ "subject": "Task 9: ForwardedMessageCard + ForwardBundleViewer", "status": "pending", "blockedBy": [7], - "description": "**Goal:** Render forward messages on receiving end — quote card single, stacked bundle card with click-to-expand modal that lazy-fetches items.\n\n**Files:** apps/client/src/components/channel/forward/{ForwardedMessageCard,ForwardBundleViewer}.tsx (+ __tests__)\n\n**Verify:** pnpm --filter @team9/client test -- ForwardedMessageCard ForwardBundleViewer\n\n```json:metadata\n{\"files\":[\"apps/client/src/components/channel/forward/ForwardedMessageCard.tsx\",\"apps/client/src/components/channel/forward/ForwardBundleViewer.tsx\",\"apps/client/src/components/channel/forward/__tests__/ForwardedMessageCard.test.tsx\",\"apps/client/src/components/channel/forward/__tests__/ForwardBundleViewer.test.tsx\"],\"verifyCommand\":\"pnpm --filter @team9/client test -- ForwardedMessageCard ForwardBundleViewer\",\"acceptanceCriteria\":[\"single vs bundle branch\",\"jump-to-original respects canJumpToOriginal\",\"source-unavailable header when channelName null\",\"viewer modal lazy-fetches via forwardApi.getItems\",\"100% coverage on both files\"]}\n```" + "description": "**Goal:** Render forward messages on receiving end \u2014 quote card single, stacked bundle card with click-to-expand modal that lazy-fetches items.\n\n**Files:** apps/client/src/components/channel/forward/{ForwardedMessageCard,ForwardBundleViewer}.tsx (+ __tests__)\n\n**Verify:** pnpm --filter @team9/client test -- ForwardedMessageCard ForwardBundleViewer\n\n```json:metadata\n{\"files\":[\"apps/client/src/components/channel/forward/ForwardedMessageCard.tsx\",\"apps/client/src/components/channel/forward/ForwardBundleViewer.tsx\",\"apps/client/src/components/channel/forward/__tests__/ForwardedMessageCard.test.tsx\",\"apps/client/src/components/channel/forward/__tests__/ForwardBundleViewer.test.tsx\"],\"verifyCommand\":\"pnpm --filter @team9/client test -- ForwardedMessageCard ForwardBundleViewer\",\"acceptanceCriteria\":[\"single vs bundle branch\",\"jump-to-original respects canJumpToOriginal\",\"source-unavailable header when channelName null\",\"viewer modal lazy-fetches via forwardApi.getItems\",\"100% coverage on both files\"]}\n```" }, { "id": 10, "subject": "Task 10: SelectionActionBar + MessageList integration + MessageItem checkbox + eligibility helper", - "status": "pending", + "status": "completed", "blockedBy": [7, 8], "description": "**Goal:** Wire selection-mode UI: per-row checkboxes (with eligibility tooltip), bottom action bar, route-change exit, Esc cancel, Shift+click range, cap toast.\n\n**Files:** apps/client/src/components/channel/forward/{SelectionActionBar,eligibility}.{tsx,ts} (+ __tests__); MessageItem.tsx; MessageList.tsx\n\n**Verify:** pnpm --filter @team9/client test -- SelectionActionBar MessageList eligibility\n\n```json:metadata\n{\"files\":[\"apps/client/src/components/channel/forward/SelectionActionBar.tsx\",\"apps/client/src/components/channel/forward/eligibility.ts\",\"apps/client/src/components/channel/forward/__tests__/SelectionActionBar.test.tsx\",\"apps/client/src/components/channel/forward/__tests__/eligibility.test.ts\",\"apps/client/src/components/channel/MessageItem.tsx\",\"apps/client/src/components/channel/MessageList.tsx\"],\"verifyCommand\":\"pnpm --filter @team9/client test -- SelectionActionBar MessageList eligibility\",\"acceptanceCriteria\":[\"checkboxes appear on rows in selection mode\",\"Shift+click range adds inclusive eligible slice\",\"Esc + route-change exits mode\",\"cap=100 enforced with toast\",\"action bar opens ForwardDialog with selectedMessages\"]}\n```" }, { "id": 11, "subject": "Task 11: Hover toolbar + context menu wiring", - "status": "pending", + "status": "completed", "blockedBy": [8, 10], "description": "**Goal:** Add Forward + Select entry points on MessageHoverToolbar and MessageContextMenu; wire from MessageItem.\n\n**Files:** MessageHoverToolbar.tsx; MessageContextMenu.tsx; MessageItem.tsx; corresponding __tests__\n\n**Verify:** pnpm --filter @team9/client test -- MessageHoverToolbar MessageContextMenu\n\n```json:metadata\n{\"files\":[\"apps/client/src/components/channel/MessageHoverToolbar.tsx\",\"apps/client/src/components/channel/MessageContextMenu.tsx\",\"apps/client/src/components/channel/MessageItem.tsx\"],\"verifyCommand\":\"pnpm --filter @team9/client test -- MessageHoverToolbar MessageContextMenu\",\"acceptanceCriteria\":[\"Forward + Select icons on hover toolbar (gated on isForwardable)\",\"Forward + Select items in context menu (with F shortcut)\",\"Forward opens ForwardDialog single; Select enters mode + toggles\",\"tests for visibility and click handlers\"]}\n```" }, { "id": 12, - "subject": "Task 12: MessageContent dispatch — render forward card when type=forward", - "status": "pending", + "subject": "Task 12: MessageContent dispatch \u2014 render forward card when type=forward", + "status": "completed", "blockedBy": [9], "description": "**Goal:** MessageContent short-circuits to when type=forward.\n\n**Files:** apps/client/src/components/channel/MessageContent.tsx (+ __tests__)\n\n**Verify:** pnpm --filter @team9/client test -- MessageContent\n\n```json:metadata\n{\"files\":[\"apps/client/src/components/channel/MessageContent.tsx\",\"apps/client/src/components/channel/__tests__/MessageContent.test.tsx\"],\"verifyCommand\":\"pnpm --filter @team9/client test -- MessageContent\",\"acceptanceCriteria\":[\"renders ForwardedMessageCard for forward type\",\"existing branches unchanged\"]}\n```" }, From 50a6e5257b7d5a44823fe389c444c35dec4377aa Mon Sep 17 00:00:00 2001 From: Winrey Date: Tue, 5 May 2026 08:17:33 +0800 Subject: [PATCH 21/23] fix(forward): apply spec/quality reviewer findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/components/channel/MessageItem.tsx | 14 +- .../src/components/channel/MessageList.tsx | 25 ++- .../channel/forward/ForwardBundleViewer.tsx | 14 +- .../channel/forward/ForwardDialog.tsx | 2 +- .../__tests__/ForwardBundleViewer.test.tsx | 6 +- .../forward/__tests__/ForwardDialog.test.tsx | 4 +- .../__tests__/ForwardItemBody.test.tsx | 183 ++++++++++++++++++ apps/client/src/i18n/locales/en/channel.json | 3 +- .../src/i18n/locales/zh-CN/channel.json | 3 +- .../forwards/forwards.service.spec.ts | 71 +++++++ .../im/messages/forwards/forwards.service.ts | 29 ++- 11 files changed, 331 insertions(+), 23 deletions(-) create mode 100644 apps/client/src/components/channel/forward/__tests__/ForwardItemBody.test.tsx diff --git a/apps/client/src/components/channel/MessageItem.tsx b/apps/client/src/components/channel/MessageItem.tsx index 3a42c451..a6cb22a6 100644 --- a/apps/client/src/components/channel/MessageItem.tsx +++ b/apps/client/src/components/channel/MessageItem.tsx @@ -167,13 +167,23 @@ export function MessageItem({ 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 < range.length) { + if (added < wouldAdd) { toast.error(t("channel:forward.tooManySelected")); } } else { const ok = selectionToggle(message.id); - if (!ok) toast.error(t("channel:forward.tooManySelected")); + 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; } }, diff --git a/apps/client/src/components/channel/MessageList.tsx b/apps/client/src/components/channel/MessageList.tsx index 82ad124d..0288e5b3 100644 --- a/apps/client/src/components/channel/MessageList.tsx +++ b/apps/client/src/components/channel/MessageList.tsx @@ -160,14 +160,33 @@ export function MessageList({ const selectionExit = useForwardSelectionStore((s) => s.exit); const [forwardOpen, setForwardOpen] = useState(false); - // Esc key exits selection mode + // Selection-mode keyboard shortcuts: Esc exits, Enter opens the forward + // dialog when at least one message is selected (spec §6.3). useEffect(() => { function onKey(e: KeyboardEvent) { - if (e.key === "Escape" && selectionActive) selectionExit(); + if (!selectionActive) return; + if (e.key === "Escape") { + selectionExit(); + return; + } + if (e.key === "Enter" && selectionIds.size > 0) { + // Don't hijack Enter while the user is typing in an input/textarea or + // composing — only fire when focus is outside an editable element. + const target = e.target as HTMLElement | null; + const tag = target?.tagName?.toLowerCase(); + if ( + tag === "input" || + tag === "textarea" || + target?.isContentEditable + ) { + return; + } + setForwardOpen(true); + } } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [selectionActive, selectionExit]); + }, [selectionActive, selectionIds, selectionExit]); // Exit selection mode when switching channels useEffect(() => { diff --git a/apps/client/src/components/channel/forward/ForwardBundleViewer.tsx b/apps/client/src/components/channel/forward/ForwardBundleViewer.tsx index cb79cf1d..75866064 100644 --- a/apps/client/src/components/channel/forward/ForwardBundleViewer.tsx +++ b/apps/client/src/components/channel/forward/ForwardBundleViewer.tsx @@ -41,7 +41,7 @@ export function ForwardBundleViewer({ {isLoading && (
- {t("forward.source.unavailable")} + {t("forward.bundle.loading")}
)} {isError && ( @@ -51,11 +51,13 @@ export function ForwardBundleViewer({ )} {data && (
    - {data.map((item) => ( -
  • - -
  • - ))} + {[...data] + .sort((a, b) => a.position - b.position) + .map((item) => ( +
  • + +
  • + ))}
)} diff --git a/apps/client/src/components/channel/forward/ForwardDialog.tsx b/apps/client/src/components/channel/forward/ForwardDialog.tsx index 21031fda..a57ef862 100644 --- a/apps/client/src/components/channel/forward/ForwardDialog.tsx +++ b/apps/client/src/components/channel/forward/ForwardDialog.tsx @@ -55,7 +55,7 @@ export function ForwardDialog({ onSuccess: (_data, channelId) => { toast(t("forward.success")); queryClient.invalidateQueries({ - queryKey: ["channelMessages", channelId], + queryKey: ["messages", channelId], }); setTargetChannelId(null); onOpenChange(false); diff --git a/apps/client/src/components/channel/forward/__tests__/ForwardBundleViewer.test.tsx b/apps/client/src/components/channel/forward/__tests__/ForwardBundleViewer.test.tsx index 9b8579fe..1552c46b 100644 --- a/apps/client/src/components/channel/forward/__tests__/ForwardBundleViewer.test.tsx +++ b/apps/client/src/components/channel/forward/__tests__/ForwardBundleViewer.test.tsx @@ -134,10 +134,8 @@ describe("ForwardBundleViewer", () => { { wrapper }, ); - // Loading state renders the unavailable text as a placeholder - expect( - screen.getByText("forward.source.unavailable"), - ).toBeInTheDocument(); + // Loading state renders the dedicated bundle-loading text + expect(screen.getByText("forward.bundle.loading")).toBeInTheDocument(); }); }); diff --git a/apps/client/src/components/channel/forward/__tests__/ForwardDialog.test.tsx b/apps/client/src/components/channel/forward/__tests__/ForwardDialog.test.tsx index 0f1ec615..068e8faa 100644 --- a/apps/client/src/components/channel/forward/__tests__/ForwardDialog.test.tsx +++ b/apps/client/src/components/channel/forward/__tests__/ForwardDialog.test.tsx @@ -571,7 +571,7 @@ describe("ForwardDialog", () => { }); describe("query invalidation on success", () => { - it("invalidates channelMessages query for target channel", async () => { + it("invalidates messages query for target channel", async () => { const qc = makeQueryClient(); const invalidateSpy = vi.spyOn(qc, "invalidateQueries"); @@ -591,7 +591,7 @@ describe("ForwardDialog", () => { await waitFor(() => { expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: ["channelMessages", "ch1"], + queryKey: ["messages", "ch1"], }); }); }); diff --git a/apps/client/src/components/channel/forward/__tests__/ForwardItemBody.test.tsx b/apps/client/src/components/channel/forward/__tests__/ForwardItemBody.test.tsx new file mode 100644 index 00000000..461c0938 --- /dev/null +++ b/apps/client/src/components/channel/forward/__tests__/ForwardItemBody.test.tsx @@ -0,0 +1,183 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import type { ForwardItem } from "@/types/im"; + +// react-i18next: pass-through so the test asserts on i18n keys, not English. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (k: string) => k, + }), +})); + +// Stub heavy renderers + avatar so this test stays isolated to ForwardItemBody. +// Use the absolute alias path because that's what the imported component +// resolves to via ../AstRenderer from inside forward/. +vi.mock("@/components/channel/AstRenderer", () => ({ + AstRenderer: ({ ast }: { ast: Record }) => ( +
{JSON.stringify(ast)}
+ ), +})); +vi.mock("@/components/ui/user-avatar", () => ({ + UserAvatar: ({ + name, + username, + }: { + name: string | null; + username: string; + }) => {name ?? username}, +})); + +import { ForwardItemBody } from "../ForwardItemBody"; + +const baseItem: ForwardItem = { + position: 0, + sourceMessageId: "msg-1", + sourceChannelId: "ch-1", + sourceChannelName: "general", + sourceWorkspaceId: "ws-1", + sourceSender: { + id: "u-1", + username: "alice", + displayName: "Alice", + avatarUrl: null, + }, + sourceCreatedAt: "2026-05-02T10:00:00Z", + sourceSeqId: "100", + sourceType: "text", + contentSnapshot: "hello world", + contentAstSnapshot: null, + attachmentsSnapshot: [], + canJumpToOriginal: true, + truncated: false, +}; + +describe("ForwardItemBody", () => { + it("renders sender display name when present", () => { + render(); + // Both the avatar mock and the name span print the resolved name. + expect(screen.getAllByText("Alice").length).toBeGreaterThan(0); + }); + + it("falls back to username when displayName is missing", () => { + render( + , + ); + expect(screen.getAllByText("alice").length).toBeGreaterThan(0); + }); + + it("renders '?' when sender is null (deleted user)", () => { + render(); + // Avatar receives "" for username; name span renders "?" + expect(screen.getByText("?")).toBeInTheDocument(); + }); + + it("renders plaintext content when contentAstSnapshot is null", () => { + render(); + expect(screen.getByText("hello world")).toBeInTheDocument(); + expect(screen.queryByTestId("ast")).toBeNull(); + }); + + it("delegates to AstRenderer when contentAstSnapshot is non-null", () => { + render( + , + ); + expect(screen.getByTestId("ast")).toBeInTheDocument(); + }); + + it("renders empty content when contentSnapshot is null and no ast", () => { + const { container } = render( + , + ); + // Just confirms no crash; the rendered span is empty + expect(container.querySelector(".whitespace-pre-wrap")).toBeInTheDocument(); + }); + + it("does not render attachments list when empty", () => { + const { container } = render(); + expect(container.querySelectorAll("ul")).toHaveLength(0); + }); + + it("renders attachment links with correct href and target", () => { + render( + , + ); + const link = screen.getByRole("link", { name: "report.pdf" }); + expect(link).toHaveAttribute( + "href", + "https://files.example.com/report.pdf", + ); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noreferrer"); + }); + + it("does not render jump button when showJumpLink is false (default)", () => { + render(); + expect(screen.queryByText("forward.source.jumpTo")).toBeNull(); + }); + + it("does not render jump button when canJumpToOriginal is false", () => { + render( + , + ); + expect(screen.queryByText("forward.source.jumpTo")).toBeNull(); + }); + + it("does not render jump button when sourceMessageId is null", () => { + render( + , + ); + expect(screen.queryByText("forward.source.jumpTo")).toBeNull(); + }); + + it("renders jump button when showJumpLink + canJumpToOriginal + sourceMessageId all truthy", () => { + const onJump = vi.fn(); + render(); + fireEvent.click(screen.getByText("forward.source.jumpTo")); + expect(onJump).toHaveBeenCalledWith(baseItem); + }); + + it("jump button click is a no-op when onJump is not provided", () => { + render(); + // Should not throw + fireEvent.click(screen.getByText("forward.source.jumpTo")); + }); +}); diff --git a/apps/client/src/i18n/locales/en/channel.json b/apps/client/src/i18n/locales/en/channel.json index c10687db..abe56639 100644 --- a/apps/client/src/i18n/locales/en/channel.json +++ b/apps/client/src/i18n/locales/en/channel.json @@ -287,7 +287,8 @@ "bundle": { "title": "Chat record · {{count}} messages", "viewAll": "View all", - "modalTitle": "Chat record from #{{channelName}}" + "modalTitle": "Chat record from #{{channelName}}", + "loading": "Loading chat record…" }, "source": { "unavailable": "Source no longer available", diff --git a/apps/client/src/i18n/locales/zh-CN/channel.json b/apps/client/src/i18n/locales/zh-CN/channel.json index 7d9f8837..a314490a 100644 --- a/apps/client/src/i18n/locales/zh-CN/channel.json +++ b/apps/client/src/i18n/locales/zh-CN/channel.json @@ -284,7 +284,8 @@ "bundle": { "title": "聊天记录 · {{count}} 条", "viewAll": "查看全部", - "modalTitle": "来自 #{{channelName}} 的聊天记录" + "modalTitle": "来自 #{{channelName}} 的聊天记录", + "loading": "正在加载聊天记录…" }, "source": { "unavailable": "原消息已不可访问", diff --git a/apps/server/apps/gateway/src/im/messages/forwards/forwards.service.spec.ts b/apps/server/apps/gateway/src/im/messages/forwards/forwards.service.spec.ts index dcc6fb8a..d5bdab2c 100644 --- a/apps/server/apps/gateway/src/im/messages/forwards/forwards.service.spec.ts +++ b/apps/server/apps/gateway/src/im/messages/forwards/forwards.service.spec.ts @@ -413,6 +413,77 @@ describe('ForwardsService', () => { ); }); + it('embeds the resolved sender name in the single-forward digest (spec §3.3)', async () => { + messagesService.findManyByIds.mockResolvedValue([ + makeMessage({ id: 'msg-1', senderId: 'user-author', content: 'hello' }), + ]); + messagesService.findUsersByIds.mockResolvedValue([ + { + id: 'user-author', + username: 'alice', + displayName: 'Alice', + avatarUrl: null, + }, + ]); + + await service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1'], + userId: 'user-1', + }); + + const call = grpcService.createMessage.mock.calls[0][0] as { + content: string; + }; + expect(call.content).toBe('[Forwarded] Alice: hello'); + }); + + it('falls back to "unknown" when sender record is unavailable', async () => { + messagesService.findManyByIds.mockResolvedValue([ + makeMessage({ id: 'msg-1', senderId: 'user-deleted', content: 'hi' }), + ]); + messagesService.findUsersByIds.mockResolvedValue([]); + + await service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1'], + userId: 'user-1', + }); + + const call = grpcService.createMessage.mock.calls[0][0] as { + content: string; + }; + expect(call.content).toBe('[Forwarded] unknown: hi'); + }); + + it('uses username when displayName is null', async () => { + messagesService.findManyByIds.mockResolvedValue([ + makeMessage({ id: 'msg-1', senderId: 'user-bob', content: 'yo' }), + ]); + messagesService.findUsersByIds.mockResolvedValue([ + { + id: 'user-bob', + username: 'bob', + displayName: null, + avatarUrl: null, + }, + ]); + + await service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1'], + userId: 'user-1', + }); + + const call = grpcService.createMessage.mock.calls[0][0] as { + content: string; + }; + expect(call.content).toBe('[Forwarded] bob: yo'); + }); + it('forwards a single image message with attachment snapshot', async () => { messagesService.findManyByIds.mockResolvedValue([ makeMessage({ type: 'image', content: null }), diff --git a/apps/server/apps/gateway/src/im/messages/forwards/forwards.service.ts b/apps/server/apps/gateway/src/im/messages/forwards/forwards.service.ts index 2c484e20..d879ee18 100644 --- a/apps/server/apps/gateway/src/im/messages/forwards/forwards.service.ts +++ b/apps/server/apps/gateway/src/im/messages/forwards/forwards.service.ts @@ -117,6 +117,19 @@ export class ForwardsService { const sourceChannel = await this.channelsService.findById(sourceChannelId); const sourceChannelName = sourceChannel?.name ?? null; + // --- Resolve sender display names so the digest carries attribution + // (spec §3.3 — single: "[Forwarded] {sender}: {content}", bundle: per-row + // sender preview). Senders may be null if the user was deleted. + const distinctSenderIds = Array.from( + new Set(ordered.map((m) => m.senderId).filter((x): x is string => !!x)), + ); + const senderRecords = distinctSenderIds.length + ? await this.messagesService.findUsersByIds(distinctSenderIds) + : []; + const senderNameById = new Map( + senderRecords.map((u) => [u.id, u.displayName ?? u.username]), + ); + const kind: ForwardKind = ordered.length === 1 ? 'single' : 'bundle'; // --- Build forward rows --- @@ -168,7 +181,12 @@ export class ForwardsService { }); const anyTruncated = items.some((i) => i.truncated); - const digest = this.buildDigest(kind, ordered, sourceChannelName); + const digest = this.buildDigest( + kind, + ordered, + sourceChannelName, + senderNameById, + ); const metadataForward: ForwardMetadata = { kind, count: ordered.length, @@ -335,15 +353,20 @@ export class ForwardsService { kind: ForwardKind, sources: { content: string | null; senderId: string | null }[], channelName: string | null, + senderNameById: Map, ): string { + const nameOf = (senderId: string | null): string => { + if (!senderId) return 'unknown'; + return senderNameById.get(senderId) ?? 'unknown'; + }; if (kind === 'single') { const m = sources[0]; const head = (m.content ?? '').slice(0, 200); - return `[Forwarded] ${head}`; + return `[Forwarded] ${nameOf(m.senderId)}: ${head}`; } const previews = sources .slice(0, 3) - .map((m) => (m.content ?? '').slice(0, 80)) + .map((m) => `${nameOf(m.senderId)}: ${(m.content ?? '').slice(0, 80)}`) .join('; '); return `[Forwarded chat record · ${sources.length} messages from #${channelName ?? 'channel'}] ${previews}`; } From a591756952db503d8559f7d5fa3f0a6a257b1f7d Mon Sep 17 00:00:00 2001 From: Winrey Date: Tue, 5 May 2026 08:33:22 +0800 Subject: [PATCH 22/23] test(forward) + docs(forward): close test gaps + update CLAUDE.md / README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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". --- CLAUDE.md | 5 +- README.md | 2 +- .../channel/__tests__/MessageList.test.tsx | 44 +++++++ .../services/api/__tests__/forward.test.ts | 108 ++++++++++++++++++ 4 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 apps/client/src/services/api/__tests__/forward.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 36e3e503..81ab6f1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ The backend follows a modular NestJS architecture with two main applications: - **Auth Module** ([apps/server/apps/gateway/src/auth](apps/server/apps/gateway/src/auth)): JWT-based authentication with Passport strategy, 7-day token expiry - **IM Module** ([apps/server/apps/gateway/src/im](apps/server/apps/gateway/src/im)): Instant messaging functionality - Channels: direct, public, private types - - Messages: text, file, image, system, long_text types with threading support (parentId) + - Messages: text, file, image, system, long_text, forward types with threading support (parentId) - Long text: messages >=20 lines or >=2000 chars auto-classified as `long_text`, truncated at API layer, full content via `GET /messages/:id/full-content` - Users: profile management, status tracking - Properties: channel property definitions, message property values (16 types), AI auto-fill @@ -102,7 +102,7 @@ The codebase supports Community and Enterprise editions via environment variable - Uses Drizzle ORM with PostgreSQL - Schemas organized by domain in [apps/server/libs/database/schemas](apps/server/libs/database/schemas): - - **im/**: users, channels, messages, channel_members, message_attachments, message_reactions, message_acks, mentions, user_channel_read_status, channel_property_definitions, message_properties, audit_logs, channel_views, channel_tabs + - **im/**: users, channels, messages, channel_members, message_attachments, message_reactions, message_acks, mentions, user_channel_read_status, channel_property_definitions, message_properties, audit_logs, channel_views, channel_tabs, message_forwards - **tenant/**: tenants, tenant_members, workspace_invitations - **wiki/**: workspace_wikis (Team9 pointer to folder9-backed wikis with permission + approval mode) - All migrations managed via `pnpm db:migrate` @@ -186,6 +186,7 @@ Wiki System: - Reactions: emoji-based reactions per message - Properties: structured key-value data per message (16 types), displayed as chips in chat view, powering Table/Board/Calendar views - Read status: per-user, per-channel tracking via `user_channel_read_status` table +- Forwarding: single-message and multi-message bundle forwarding via `POST /api/v1/im/channels/:id/forward`; original message snapshots stored in `im_message_forwards`; full items fetched via `GET /api/v1/im/messages/:id/forward-items` ### Key Development Patterns diff --git a/README.md b/README.md index 389a57df..9b5b9e98 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ _Works great for solo power users too._ 💻 **Cross-Platform** — macOS, Windows desktop + Web -💬 **Rich Messaging** — threads, @mentions, reactions, file sharing +💬 **Rich Messaging** — threads, @mentions, reactions, file sharing, message forwarding 🏢 **Multi-Workspace** — different projects, different teams, fully isolated diff --git a/apps/client/src/components/channel/__tests__/MessageList.test.tsx b/apps/client/src/components/channel/__tests__/MessageList.test.tsx index f94ae0eb..b25294d3 100644 --- a/apps/client/src/components/channel/__tests__/MessageList.test.tsx +++ b/apps/client/src/components/channel/__tests__/MessageList.test.tsx @@ -901,6 +901,50 @@ describe("MessageList — selection mode", () => { expect(useForwardSelectionStore.getState().active).toBe(false); }); + it("Enter keypress opens the ForwardDialog when messages are selected", () => { + useForwardSelectionStore.getState().enter("ch-1"); + useForwardSelectionStore.getState().toggle("m1"); + + const chrono = [makeMessage("m1")]; + renderList(chrono); + + expect(screen.queryByTestId("forward-dialog")).not.toBeInTheDocument(); + + // Make sure focus is not in an editable element so the guard passes. + fireEvent.keyDown(window, { key: "Enter" }); + + expect(screen.getByTestId("forward-dialog")).toBeInTheDocument(); + }); + + it("Enter keypress is ignored when nothing is selected (no dialog opens)", () => { + useForwardSelectionStore.getState().enter("ch-1"); + + const chrono = [makeMessage("m1")]; + renderList(chrono); + + fireEvent.keyDown(window, { key: "Enter" }); + + expect(screen.queryByTestId("forward-dialog")).not.toBeInTheDocument(); + }); + + it("Enter keypress is ignored when focus is inside an editable element", () => { + useForwardSelectionStore.getState().enter("ch-1"); + useForwardSelectionStore.getState().toggle("m1"); + + const chrono = [makeMessage("m1")]; + const { container } = renderList(chrono); + + // Mount a textarea inside the rendered tree and focus it. + const ta = document.createElement("textarea"); + container.appendChild(ta); + ta.focus(); + + fireEvent.keyDown(ta, { key: "Enter" }); + + // Dialog must NOT open while the user is composing in an input. + expect(screen.queryByTestId("forward-dialog")).not.toBeInTheDocument(); + }); + it("re-rendering with a different channelId when selection was active calls exit", () => { useForwardSelectionStore.getState().enter("ch-1"); diff --git a/apps/client/src/services/api/__tests__/forward.test.ts b/apps/client/src/services/api/__tests__/forward.test.ts new file mode 100644 index 00000000..8b4310b3 --- /dev/null +++ b/apps/client/src/services/api/__tests__/forward.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockHttp = vi.hoisted(() => ({ + post: vi.fn(), + get: vi.fn(), +})); + +vi.mock("../../http", () => ({ + default: mockHttp, +})); + +vi.mock("../normalize-reactions", () => ({ + normalizeMessage: (m: unknown) => ({ ...(m as object), normalized: true }), +})); + +import forwardApi from "../forward"; + +describe("forwardApi.create", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockHttp.post.mockResolvedValue({ data: { id: "new-msg-1" } }); + }); + + it("posts to the correct URL with the request body", async () => { + await forwardApi.create({ + targetChannelId: "ch-target", + sourceChannelId: "ch-source", + sourceMessageIds: ["m-1", "m-2"], + clientMsgId: "cid-7", + }); + + expect(mockHttp.post).toHaveBeenCalledWith( + "/v1/im/channels/ch-target/forward", + { + sourceChannelId: "ch-source", + sourceMessageIds: ["m-1", "m-2"], + clientMsgId: "cid-7", + }, + ); + }); + + it("omits clientMsgId from the body when not provided (passes undefined)", async () => { + await forwardApi.create({ + targetChannelId: "ch-1", + sourceChannelId: "ch-2", + sourceMessageIds: ["m-1"], + }); + + expect(mockHttp.post).toHaveBeenCalledWith( + "/v1/im/channels/ch-1/forward", + expect.objectContaining({ clientMsgId: undefined }), + ); + }); + + it("returns the response data passed through normalizeMessage", async () => { + mockHttp.post.mockResolvedValueOnce({ + data: { id: "raw", reactions: [] }, + }); + const result = await forwardApi.create({ + targetChannelId: "ch-1", + sourceChannelId: "ch-2", + sourceMessageIds: ["m-1"], + }); + expect(result).toMatchObject({ id: "raw", normalized: true }); + }); + + it("propagates http errors", async () => { + mockHttp.post.mockRejectedValueOnce(new Error("forward.noWriteAccess")); + await expect( + forwardApi.create({ + targetChannelId: "ch-1", + sourceChannelId: "ch-2", + sourceMessageIds: ["m-1"], + }), + ).rejects.toThrow("forward.noWriteAccess"); + }); +}); + +describe("forwardApi.getItems", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("gets the correct URL", async () => { + mockHttp.get.mockResolvedValueOnce({ data: [] }); + await forwardApi.getItems("msg-42"); + expect(mockHttp.get).toHaveBeenCalledWith( + "/v1/im/messages/msg-42/forward-items", + ); + }); + + it("returns the response data array as-is", async () => { + const items = [ + { position: 0, sourceMessageId: "x" }, + { position: 1, sourceMessageId: "y" }, + ]; + mockHttp.get.mockResolvedValueOnce({ data: items }); + const result = await forwardApi.getItems("msg-42"); + expect(result).toEqual(items); + }); + + it("propagates http errors", async () => { + mockHttp.get.mockRejectedValueOnce(new Error("forward.notFound")); + await expect(forwardApi.getItems("msg-missing")).rejects.toThrow( + "forward.notFound", + ); + }); +}); From aaad162ceb8cb399d1b42ee8fce6210e83df24aa Mon Sep 17 00:00:00 2001 From: Winrey Date: Tue, 5 May 2026 08:42:07 +0800 Subject: [PATCH 23/23] fix(forward): apply Copilot review #101 findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../src/components/channel/MessageItem.tsx | 23 ++++--- .../useForwardSelectionStore.test.ts | 60 +++++++++++++++++++ .../src/stores/useForwardSelectionStore.ts | 33 ++++++++-- .../forwards/forwards.service.spec.ts | 40 +++++++++++++ .../im/messages/forwards/forwards.service.ts | 16 +++-- 5 files changed, 154 insertions(+), 18 deletions(-) diff --git a/apps/client/src/components/channel/MessageItem.tsx b/apps/client/src/components/channel/MessageItem.tsx index a6cb22a6..f3579281 100644 --- a/apps/client/src/components/channel/MessageItem.tsx +++ b/apps/client/src/components/channel/MessageItem.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Loader2, AlertCircle, Plus, RotateCcw, Tags, X } from "lucide-react"; import { toast } from "sonner"; @@ -147,7 +147,6 @@ export function MessageItem({ selectionActive && selectionChannelId === message.channelId; const isEligible = isForwardable(message); const isSelected = inSelectionMode && isSelectedFn(message.id); - const lastAnchorRef = useRef(null); // Forward dialog state const [forwardOpen, setForwardOpen] = useState(false); @@ -161,30 +160,36 @@ export function MessageItem({ const toggleSelection = useCallback( (shiftKey: boolean) => { if (!isEligible) return; - if (shiftKey && lastAnchorRef.current && visibleMessages) { + // Anchor lives in the store so it survives across MessageItem instances — + // shift+click from message A to message B reads A's anchor from there + // (Copilot review #101 finding). + const storeState = useForwardSelectionStore.getState(); + const anchorId = storeState.anchorId; + if (shiftKey && anchorId && visibleMessages) { const range = computeForwardableRange( visibleMessages, - lastAnchorRef.current, + anchorId, 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 beforeIds = storeState.selectedIds; const wouldAdd = range.filter((id) => !beforeIds.has(id)).length; const added = selectionAddRange(range); if (added < wouldAdd) { toast.error(t("channel:forward.tooManySelected")); } + // Don't update the anchor on a range-add: preserve the original + // anchor so the user can extend the same range further. } 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. + // selectionToggle already left the prior anchor in place when it + // returned false (it didn't fire its `set` call), so nothing to do. return; } - lastAnchorRef.current = message.id; + // selectionToggle.set(...) updated anchorId to message.id for us. } }, [ diff --git a/apps/client/src/stores/__tests__/useForwardSelectionStore.test.ts b/apps/client/src/stores/__tests__/useForwardSelectionStore.test.ts index 7b5f045a..e2ea4652 100644 --- a/apps/client/src/stores/__tests__/useForwardSelectionStore.test.ts +++ b/apps/client/src/stores/__tests__/useForwardSelectionStore.test.ts @@ -110,4 +110,64 @@ describe("useForwardSelectionStore", () => { it("FORWARD_SELECTION_MAX is 100", () => { expect(FORWARD_SELECTION_MAX).toBe(100); }); + + describe("anchor (shift+click anchor lives in store, not on each row)", () => { + it("toggle sets anchorId to the toggled message id", () => { + useForwardSelectionStore.getState().enter("ch-1"); + useForwardSelectionStore.getState().toggle("m-A"); + expect(useForwardSelectionStore.getState().anchorId).toBe("m-A"); + }); + + it("toggle (deselect) also updates anchorId", () => { + useForwardSelectionStore.getState().enter("ch-1"); + useForwardSelectionStore.getState().toggle("m-A"); // select + useForwardSelectionStore.getState().toggle("m-A"); // deselect + expect(useForwardSelectionStore.getState().anchorId).toBe("m-A"); + }); + + it("toggle that hits the cap leaves anchorId unchanged", () => { + useForwardSelectionStore.getState().enter("ch-1"); + for (let i = 0; i < FORWARD_SELECTION_MAX; i += 1) { + useForwardSelectionStore.getState().toggle(`m-${i}`); + } + const lastAnchor = useForwardSelectionStore.getState().anchorId; + useForwardSelectionStore.getState().toggle("m-overflow"); + expect(useForwardSelectionStore.getState().anchorId).toBe(lastAnchor); + }); + + it("addRange does NOT change anchorId — preserves the shift-extension anchor", () => { + useForwardSelectionStore.getState().enter("ch-1"); + useForwardSelectionStore.getState().toggle("m-anchor"); + useForwardSelectionStore.getState().addRange(["m-1", "m-2", "m-3"]); + expect(useForwardSelectionStore.getState().anchorId).toBe("m-anchor"); + }); + + it("enter() resets anchorId to null", () => { + useForwardSelectionStore.getState().enter("ch-1"); + useForwardSelectionStore.getState().toggle("m-A"); + useForwardSelectionStore.getState().enter("ch-2"); + expect(useForwardSelectionStore.getState().anchorId).toBeNull(); + }); + + it("exit() resets anchorId to null", () => { + useForwardSelectionStore.getState().enter("ch-1"); + useForwardSelectionStore.getState().toggle("m-A"); + useForwardSelectionStore.getState().exit(); + expect(useForwardSelectionStore.getState().anchorId).toBeNull(); + }); + + it("clear() resets anchorId to null", () => { + useForwardSelectionStore.getState().enter("ch-1"); + useForwardSelectionStore.getState().toggle("m-A"); + useForwardSelectionStore.getState().clear(); + expect(useForwardSelectionStore.getState().anchorId).toBeNull(); + }); + + it("setAnchor explicitly sets the anchor without toggling selection", () => { + useForwardSelectionStore.getState().enter("ch-1"); + useForwardSelectionStore.getState().setAnchor("m-x"); + expect(useForwardSelectionStore.getState().anchorId).toBe("m-x"); + expect(useForwardSelectionStore.getState().selectedIds.size).toBe(0); + }); + }); }); diff --git a/apps/client/src/stores/useForwardSelectionStore.ts b/apps/client/src/stores/useForwardSelectionStore.ts index 96f3490c..a345ff91 100644 --- a/apps/client/src/stores/useForwardSelectionStore.ts +++ b/apps/client/src/stores/useForwardSelectionStore.ts @@ -7,12 +7,22 @@ interface ForwardSelectionState { active: boolean; channelId: string | null; selectedIds: Set; + /** + * Last single-click anchor used by Shift+click range selection. + * + * MUST live in the store rather than in a per-MessageItem ref — + * otherwise the second click's MessageItem instance has no anchor + * (Copilot review #101 finding). `toggle` updates this; `addRange` + * preserves it; `enter`/`exit`/`clear` reset it. + */ + anchorId: string | null; enter: (channelId: string) => void; exit: () => void; toggle: (messageId: string) => boolean; addRange: (messageIds: string[]) => number; clear: () => void; isSelected: (messageId: string) => boolean; + setAnchor: (messageId: string | null) => void; } export const useForwardSelectionStore = create( @@ -20,21 +30,33 @@ export const useForwardSelectionStore = create( active: false, channelId: null, selectedIds: new Set(), + anchorId: null, enter: (channelId) => - set({ active: true, channelId, selectedIds: new Set() }), - exit: () => set({ active: false, channelId: null, selectedIds: new Set() }), + set({ + active: true, + channelId, + selectedIds: new Set(), + anchorId: null, + }), + exit: () => + set({ + active: false, + channelId: null, + selectedIds: new Set(), + anchorId: null, + }), toggle: (messageId) => { const state = get(); if (!state.active) return false; const next = new Set(state.selectedIds); if (next.has(messageId)) { next.delete(messageId); - set({ selectedIds: next }); + set({ selectedIds: next, anchorId: messageId }); return true; } if (next.size >= MAX_SELECTED) return false; next.add(messageId); - set({ selectedIds: next }); + set({ selectedIds: next, anchorId: messageId }); return true; }, addRange: (messageIds) => { @@ -52,7 +74,8 @@ export const useForwardSelectionStore = create( if (added > 0) set({ selectedIds: next }); return added; }, - clear: () => set({ selectedIds: new Set() }), + clear: () => set({ selectedIds: new Set(), anchorId: null }), isSelected: (messageId) => get().selectedIds.has(messageId), + setAnchor: (messageId) => set({ anchorId: messageId }), }), ); diff --git a/apps/server/apps/gateway/src/im/messages/forwards/forwards.service.spec.ts b/apps/server/apps/gateway/src/im/messages/forwards/forwards.service.spec.ts index d5bdab2c..158f6809 100644 --- a/apps/server/apps/gateway/src/im/messages/forwards/forwards.service.spec.ts +++ b/apps/server/apps/gateway/src/im/messages/forwards/forwards.service.spec.ts @@ -237,6 +237,31 @@ describe('ForwardsService', () => { ).rejects.toMatchObject({ message: 'forward.empty' }); }); + it('dedupes duplicate sourceMessageIds before lookup (no spurious notFound)', async () => { + // Copilot review #101 finding: passing duplicate ids would otherwise + // trigger forward.notFound because findManyByIds returns unique rows. + messagesService.findManyByIds.mockResolvedValueOnce([ + makeMessage({ id: 'msg-1' }), + ]); + + const result = await service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1', 'msg-1', 'msg-1'], + userId: 'user-1', + }); + + expect(result.type).toBe('forward'); + // Bundle->single because dedupe collapses to one + expect(grpcService.createMessage).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + forward: expect.objectContaining({ kind: 'single', count: 1 }), + }), + }), + ); + }); + it('rejects sourceMessageIds.length > 100 with forward.tooManySelected', async () => { const ids = Array.from({ length: 101 }, (_, i) => `msg-${i}`); await expect( @@ -399,6 +424,21 @@ describe('ForwardsService', () => { // ── single forward happy paths ──────────────────────────────────────────── describe('single forward', () => { + it('passes userId to getMessageWithDetails so the response includes hydrated forward payload (Copilot #101)', async () => { + await service.forward({ + targetChannelId: 'ch-target', + sourceChannelId: 'ch-src', + sourceMessageIds: ['msg-1'], + userId: 'user-1', + }); + // Without userId, MessagesService.getMessageWithDetails skips + // hydration and the client receives a forward without its payload. + expect(messagesService.getMessageWithDetails).toHaveBeenCalledWith( + expect.any(String), + 'user-1', + ); + }); + it('forwards a single text message and returns forward type response', async () => { const result = await service.forward({ targetChannelId: 'ch-target', diff --git a/apps/server/apps/gateway/src/im/messages/forwards/forwards.service.ts b/apps/server/apps/gateway/src/im/messages/forwards/forwards.service.ts index d879ee18..34361b9c 100644 --- a/apps/server/apps/gateway/src/im/messages/forwards/forwards.service.ts +++ b/apps/server/apps/gateway/src/im/messages/forwards/forwards.service.ts @@ -54,8 +54,11 @@ export class ForwardsService { ) {} async forward(input: ForwardInput): Promise { - const { targetChannelId, sourceChannelId, sourceMessageIds, userId } = - input; + const { targetChannelId, sourceChannelId, userId } = input; + // Dedupe IDs while preserving the first occurrence's order. `findManyByIds` + // returns a unique row per id, so duplicates would otherwise trigger a + // spurious `forward.notFound` (Copilot review #101 finding). + const sourceMessageIds = Array.from(new Set(input.sourceMessageIds)); // --- Validation --- if (sourceMessageIds.length === 0) { @@ -223,8 +226,13 @@ export class ForwardsService { throw new InternalServerErrorException('forward.insertFailed'); } - const message = - await this.messagesService.getMessageWithDetails(forwardedMessageId); + // Pass userId so the forward payload is hydrated on the response — + // otherwise the freshly-created forward message would render blank + // on the client until a manual refetch (Copilot review #101 finding). + const message = await this.messagesService.getMessageWithDetails( + forwardedMessageId, + userId, + ); return this.messagesService.truncateForPreview(message); }