Skip to content

feat(conversations): bulk archive/unarchive, read filter, and status banners#817

Merged
Israeltheminer merged 4 commits into
mainfrom
feat/bulk-archive-status-banners
Mar 19, 2026
Merged

feat(conversations): bulk archive/unarchive, read filter, and status banners#817
Israeltheminer merged 4 commits into
mainfrom
feat/bulk-archive-status-banners

Conversation

@Israeltheminer
Copy link
Copy Markdown
Collaborator

@Israeltheminer Israeltheminer commented Mar 19, 2026

Summary

  • Add bulk archive and unarchive backend mutations with frontend hooks and toast feedback
  • Redesign inbox toolbar with icon-button bulk actions, read/unread radio filter, and selected-count display
  • Add contextual action banners (reopen/unarchive/not-spam) at bottom of conversation panel for closed, archived, and spam states
  • Implement thread collapsing for long conversations and error state with retry
  • Update conversation list row layout with tighter spacing, unread dot indicator, and subject preview

Test plan

  • Verify bulk archive/unarchive works on selected conversations with correct toast messages
  • Test read/unread filter dropdown filters conversation list correctly
  • Confirm action banners appear for closed, archived, and spam conversations
  • Test reopen/unarchive/not-spam buttons from banners
  • Verify thread collapse shows "Show earlier messages" for conversations with >4 messages
  • Test error state and retry button when conversation fails to load
  • Verify conversation list row layout matches updated design

Summary by CodeRabbit

  • New Features

    • Added conversation read/unread filtering
    • Enabled bulk archive and unarchive operations
    • Added ability to reopen closed, archived, and spam conversations
    • Introduced thread collapsing for extended conversations
    • Replaced numeric unread badge with visual indicator
  • Bug Fixes

    • Added error handling with retry functionality in conversation panels
  • Improvements

    • Updated conversation list to prioritize customer name display
    • Refined bulk action toolbar with icon-based controls
    • Enhanced conversation panel header layout

Copy link
Copy Markdown

@greptile-apps greptile-apps Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your free trial has ended. If you'd like to continue receiving code reviews, you can add a payment method here.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 19, 2026

📝 Walkthrough

Walkthrough

This PR introduces conversation status management and bulk archive/unarchive operations across the platform. Changes include: removing an icon from the empty state component, adding an optional status prop to the conversation panel with conditional rendering for error states and status-specific banners, restructuring the conversation list to replace numeric unread badges with visual indicators, implementing thread collapsing logic, adding read/unread filtering to the conversations list, enhancing bulk actions with archive/unarchive operations including new Convex mutations and hooks, updating skeleton loaders with status-aware rendering, and extending locale strings to support new UI elements and copy.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main changes: bulk archive/unarchive functionality, read filter, and status banners for conversations.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Important

Merge conflicts detected (Beta)

  • Resolve merge conflict in branch feat/bulk-archive-status-banners
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/bulk-archive-status-banners
📝 Coding Plan
  • Generate coding plan for human review comments

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@services/platform/app/features/conversations/components/conversation-panel.tsx`:
- Around line 355-359: The hidden-count is hardcoded as totalMessages - 2 but
the preview actually renders up to Math.min(2, lastDateGroupCount) messages from
the final date group; change collapsedHiddenCount to subtract the actual number
of messages rendered in the collapsed preview. Specifically, inspect
displayMessages/grouping logic used in the preview render (the same code that
renders the last date group), compute lastGroupCount (number of messages in the
final date group) and visibleInCollapsed = Math.min(2, lastGroupCount), then set
collapsedHiddenCount = totalMessages - visibleInCollapsed and use that value
wherever the CTA count is shown (replace the hardcoded subtraction near
collapsedHiddenCount and the similar occurrence around lines 400-407).
- Around line 89-90: The collapse state is persistent across conversation
switches; add logic to reinitialize it whenever selectedConversationId changes
by adding an effect that calls setIsThreadCollapsed(true) (or whatever the
default collapsed value should be) whenever selectedConversationId updates.
Locate the state variables isThreadCollapsed and setIsThreadCollapsed in
conversation-panel.tsx and add a useEffect(() => setIsThreadCollapsed(true),
[selectedConversationId]) to reset the UI per selectedConversationId.

In
`@services/platform/app/features/conversations/components/conversations-list.tsx`:
- Around line 261-263: Replace the hardcoded 'Unknown' fallback in the JSX
expression ({conversation.customer?.name || conversation?.title || 'Unknown'})
with the repository's shared i18n unknown label; import and use the existing
translation helper (e.g., t or useTranslation) and substitute the fallback with
the translated key (for example: {conversation.customer?.name ||
conversation?.title || t('shared.unknownLabel')}) inside the ConversationsList
component so the fallback is localized.
- Around line 265-267: The blue unread indicator (span rendered when
conversation.unread_count > 0) is currently purely visual and not exposed to
assistive tech; make the dot decorative by ensuring the span has
aria-hidden="true" (or equivalent) and update the conversation row/button
accessible name to include an "unread" hint when conversation.unread_count > 0
(e.g., append a visually-hidden/Screen Reader only text or include "unread" in
the aria-label for the element that represents the conversation row/button).
Target the span that checks conversation.unread_count > 0 and the parent
button/element that sets the accessible name to implement this change.

In `@services/platform/app/features/conversations/components/conversations.tsx`:
- Around line 277-287: The bulk "mark spam" icon is actionable but has no
onClick hookup; pull the spam handler from useBulkActions (e.g., const {
markSpam, isBulkProcessing } = useBulkActions()) and wire it to the Button's
onClick (onClick={() => markSpam(selectedIds)}), keeping the existing
disabled={isBulkProcessing} and aria-label usage; if the hook does not expose a
spam handler, conditionally hide the Tooltip/Button when markSpam is undefined
to avoid showing a non-functional control.
- Around line 190-215: The dropdown trigger is currently a plain div and is not
keyboard-focusable, preventing keyboard/AT users from opening the read filter;
replace the trigger div with a semantic, focusable element (e.g., a <button
type="button"> or the DropdownMenu-provided Trigger component) keeping the same
className logic (including the readFilter !== 'all' bg class), preserve the
inner stopPropagation handlers around the Checkbox (id "select-all",
selectAllChecked, onCheckedChange handleSelectAll) so checkbox clicks don't open
the menu, and add appropriate accessibility attributes (aria-haspopup="menu" and
aria-expanded bound to the menu state) so the DropdownMenu/trigger behavior
remains keyboard accessible.

In `@services/platform/convex/conversations/bulk_archive_conversations.ts`:
- Around line 8-16: The bulkArchiveConversations handler currently fetches
conversation rows and proceeds to build patches/audit entries without verifying
tenant ownership; update the function signature to accept args.organizationId
and, after loading each conversation from args.conversationIds (the array used
in ctx.db.get calls) validate that conversation.organizationId ===
args.organizationId and reject (throw) on any mismatch before constructing
patches or inserting audit rows (including where
firstValidConversation.organizationId is used); apply the same per-record org
check to the other similar blocks referenced (lines 24-44 and 65-84) so no
cross-org conversation is mutated or logged.

In `@services/platform/convex/conversations/bulk_unarchive_conversations.ts`:
- Around line 8-16: In bulkUnarchiveConversations, validate organization
ownership for each loaded conversation: after fetching conversations (the
conversations array) check that args.organizationId ===
conversation.organizationId for every conversation (and before using
firstValidConversation for logging or before any ctx.db.patch/insert), and
reject or skip any conversation IDs that belong to a different org (return a
failed BulkOperationResult or omit them and report them as errors) so no
cross-org records are modified or misattributed; apply the same per-conversation
org check before any subsequent patches/inserts in the function.

In `@services/platform/convex/conversations/mutations.ts`:
- Around line 122-129: The bulk conversation mutations lack org-scoped
authorization: add an organizationId arg to the mutation signatures (e.g., in
the mutationWithRLS for bulkArchiveConversations and the similar bulkUnarchive
mutation) and, inside the handler flow (and in the corresponding helper
functions ConversationsHelpers.bulkArchiveConversations and
ConversationsHelpers.bulkUnarchiveConversations), after fetching each
conversation via ctx.db.get(id) immediately validate that
conversation.organizationId === args.organizationId and reject (skip/throw) any
that do not match before queuing any patch or insert; ensure this check runs
before any database patch/insert or audit log enqueueing so no cross-org updates
or misattributed audit entries can occur.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 89168f92-9606-48a0-a0fc-1fdae778c8cc

📥 Commits

Reviewing files that changed from the base of the PR and between bc84738 and a623649.

⛔ Files ignored due to path filters (1)
  • services/platform/convex/_generated/api.d.ts is excluded by !**/_generated/**
📒 Files selected for processing (12)
  • services/platform/app/features/conversations/components/activate-conversations-empty-state.tsx
  • services/platform/app/features/conversations/components/conversation-panel.tsx
  • services/platform/app/features/conversations/components/conversations-list.tsx
  • services/platform/app/features/conversations/components/conversations-skeleton.tsx
  • services/platform/app/features/conversations/components/conversations.tsx
  • services/platform/app/features/conversations/hooks/mutations.ts
  • services/platform/app/features/conversations/hooks/use-bulk-actions.ts
  • services/platform/convex/conversations/bulk_archive_conversations.ts
  • services/platform/convex/conversations/bulk_unarchive_conversations.ts
  • services/platform/convex/conversations/helpers.ts
  • services/platform/convex/conversations/mutations.ts
  • services/platform/messages/en.json

Comment on lines +89 to 90
const [isThreadCollapsed, setIsThreadCollapsed] = useState(true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Reset the collapse state when the selected conversation changes.

isThreadCollapsed lives for the lifetime of the panel, so after a user expands one long thread, the next selected thread will stay expanded too. The new collapse UX should reinitialize per selectedConversationId.

♻️ Suggested fix
   const [isThreadCollapsed, setIsThreadCollapsed] = useState(true);
+
+  useEffect(() => {
+    setIsThreadCollapsed(true);
+  }, [selectedConversationId]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const [isThreadCollapsed, setIsThreadCollapsed] = useState(true);
const [isThreadCollapsed, setIsThreadCollapsed] = useState(true);
useEffect(() => {
setIsThreadCollapsed(true);
}, [selectedConversationId]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@services/platform/app/features/conversations/components/conversation-panel.tsx`
around lines 89 - 90, The collapse state is persistent across conversation
switches; add logic to reinitialize it whenever selectedConversationId changes
by adding an effect that calls setIsThreadCollapsed(true) (or whatever the
default collapsed value should be) whenever selectedConversationId updates.
Locate the state variables isThreadCollapsed and setIsThreadCollapsed in
conversation-panel.tsx and add a useEffect(() => setIsThreadCollapsed(true),
[selectedConversationId]) to reset the UI per selectedConversationId.

Comment on lines +355 to +359
const totalMessages = displayMessages.length;
const COLLAPSE_THRESHOLD = 4;
const showCollapse = totalMessages > COLLAPSE_THRESHOLD && isThreadCollapsed;
const collapsedHiddenCount = totalMessages - 2;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Derive the hidden-message count from the preview you actually render.

Line 358 hardcodes totalMessages - 2, but Lines 403-407 only render up to two messages from the last date group. When that final group contains one message, the CTA count is off by one because the UI subtracts two even though only one message remains visible.

Also applies to: 400-407

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@services/platform/app/features/conversations/components/conversation-panel.tsx`
around lines 355 - 359, The hidden-count is hardcoded as totalMessages - 2 but
the preview actually renders up to Math.min(2, lastDateGroupCount) messages from
the final date group; change collapsedHiddenCount to subtract the actual number
of messages rendered in the collapsed preview. Specifically, inspect
displayMessages/grouping logic used in the preview render (the same code that
renders the last date group), compute lastGroupCount (number of messages in the
final date group) and visibleInCollapsed = Math.min(2, lastGroupCount), then set
collapsedHiddenCount = totalMessages - visibleInCollapsed and use that value
wherever the CTA count is shown (replace the hardcoded subtraction near
collapsedHiddenCount and the similar occurrence around lines 400-407).

Comment on lines +261 to +263
{conversation.customer?.name ||
conversation?.title ||
'Unknown'}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Localize the fallback title instead of hardcoding English.

'Unknown' is now user-visible text inside a translated view. Please source this from i18n so non-English locales don't regress here. The repo already has shared unknown-label copy available.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@services/platform/app/features/conversations/components/conversations-list.tsx`
around lines 261 - 263, Replace the hardcoded 'Unknown' fallback in the JSX
expression ({conversation.customer?.name || conversation?.title || 'Unknown'})
with the repository's shared i18n unknown label; import and use the existing
translation helper (e.g., t or useTranslation) and substitute the fallback with
the translated key (for example: {conversation.customer?.name ||
conversation?.title || t('shared.unknownLabel')}) inside the ConversationsList
component so the fallback is localized.

Comment on lines +265 to +267
{conversation.unread_count > 0 && (
<span className="size-1.5 shrink-0 rounded-full bg-blue-500" />
)}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Expose unread state to assistive tech.

The new blue dot is purely visual, so screen-reader users can no longer tell which conversations are unread. Keep the dot decorative and add unread text to the button’s accessible name.

♿ Suggested fix
               {conversation.unread_count > 0 && (
-                <span className="size-1.5 shrink-0 rounded-full bg-blue-500" />
+                <>
+                  <span
+                    aria-hidden="true"
+                    className="size-1.5 shrink-0 rounded-full bg-blue-500"
+                  />
+                  <span className="sr-only">{t('filter.unread')}</span>
+                </>
               )}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{conversation.unread_count > 0 && (
<span className="size-1.5 shrink-0 rounded-full bg-blue-500" />
)}
{conversation.unread_count > 0 && (
<>
<span
aria-hidden="true"
className="size-1.5 shrink-0 rounded-full bg-blue-500"
/>
<span className="sr-only">{t('filter.unread')}</span>
</>
)}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@services/platform/app/features/conversations/components/conversations-list.tsx`
around lines 265 - 267, The blue unread indicator (span rendered when
conversation.unread_count > 0) is currently purely visual and not exposed to
assistive tech; make the dot decorative by ensuring the span has
aria-hidden="true" (or equivalent) and update the conversation row/button
accessible name to include an "unread" hint when conversation.unread_count > 0
(e.g., append a visually-hidden/Screen Reader only text or include "unread" in
the aria-label for the element that represents the conversation row/button).
Target the span that checks conversation.unread_count > 0 and the parent
button/element that sets the accessible name to implement this change.

Comment on lines +190 to +215
<DropdownMenu
trigger={
<div
className={cn(
'flex shrink-0 items-center gap-0.5 rounded pr-1 py-0.5',
readFilter !== 'all' && 'bg-blue-100',
)}
>
{/* Prevent checkbox clicks from opening the dropdown */}
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<Checkbox
id="select-all"
checked={selectAllChecked}
onCheckedChange={handleSelectAll}
aria-label={tCommon('aria.selectAll')}
disabled={isLoading}
/>
</div>
<ChevronDownIcon className="text-muted-foreground size-3.5" />
</div>
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use a keyboard-focusable trigger for the read filter.

Line 192 renders the dropdown trigger as a plain div, so the new filter control cannot be focused or opened from the keyboard. That blocks the read/unread filter for keyboard and assistive-tech users.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/app/features/conversations/components/conversations.tsx`
around lines 190 - 215, The dropdown trigger is currently a plain div and is not
keyboard-focusable, preventing keyboard/AT users from opening the read filter;
replace the trigger div with a semantic, focusable element (e.g., a <button
type="button"> or the DropdownMenu-provided Trigger component) keeping the same
className logic (including the readFilter !== 'all' bg class), preserve the
inner stopPropagation handlers around the Checkbox (id "select-all",
selectAllChecked, onCheckedChange handleSelectAll) so checkbox clicks don't open
the menu, and add appropriate accessibility attributes (aria-haspopup="menu" and
aria-expanded bound to the menu state) so the DropdownMenu/trigger behavior
remains keyboard accessible.

Comment on lines +277 to +287
{status === 'open' && (
<Tooltip content={tConversations('bulk.markSpam')}>
<Button
size="icon"
variant="ghost"
disabled={isBulkProcessing}
aria-label={tConversations('bulk.markSpam')}
>
<ShieldXIcon className="size-4" />
</Button>
</Tooltip>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Wire the bulk “mark spam” action or hide it.

Line 279 shows an enabled bulk spam action, but the button never gets an onClick, and this component does not receive any spam handler from useBulkActions. Right now the icon looks actionable but does nothing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/app/features/conversations/components/conversations.tsx`
around lines 277 - 287, The bulk "mark spam" icon is actionable but has no
onClick hookup; pull the spam handler from useBulkActions (e.g., const {
markSpam, isBulkProcessing } = useBulkActions()) and wire it to the Button's
onClick (onClick={() => markSpam(selectedIds)}), keeping the existing
disabled={isBulkProcessing} and aria-label usage; if the hook does not expose a
spam handler, conditionally hide the Tooltip/Button when markSpam is undefined
to avoid showing a non-functional control.

Comment on lines +8 to +16
export async function bulkArchiveConversations(
ctx: MutationCtx,
args: {
conversationIds: Array<Id<'conversations'>>;
},
): Promise<BulkOperationResult> {
const conversations = await Promise.all(
args.conversationIds.map((id) => ctx.db.get(id)),
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Reject cross-org conversation IDs before archiving.

This helper archives every fetched conversation ID without checking that each record belongs to the caller’s organization. If a leaked ID from another org reaches this path, we will mutate it and then write the audit entry under firstValidConversation.organizationId, which is the wrong tenant context. Please pass organizationId through the mutation args and fail any mismatch before building patches.

Based on learnings: Enforce organization isolation for conversation write paths. After loading/fetching the conversation, verify that args.organizationId === conversation.organizationId before performing any inserts or patches.

Also applies to: 24-44, 65-84

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/convex/conversations/bulk_archive_conversations.ts` around
lines 8 - 16, The bulkArchiveConversations handler currently fetches
conversation rows and proceeds to build patches/audit entries without verifying
tenant ownership; update the function signature to accept args.organizationId
and, after loading each conversation from args.conversationIds (the array used
in ctx.db.get calls) validate that conversation.organizationId ===
args.organizationId and reject (throw) on any mismatch before constructing
patches or inserting audit rows (including where
firstValidConversation.organizationId is used); apply the same per-record org
check to the other similar blocks referenced (lines 24-44 and 65-84) so no
cross-org conversation is mutated or logged.

Comment on lines +8 to +16
export async function bulkUnarchiveConversations(
ctx: MutationCtx,
args: {
conversationIds: Array<Id<'conversations'>>;
},
): Promise<BulkOperationResult> {
const conversations = await Promise.all(
args.conversationIds.map((id) => ctx.db.get(id)),
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Reject cross-org conversation IDs before unarchiving.

Like the archive helper, this mutation trusts raw conversation IDs and patches whatever records it can load, but it never verifies org ownership per conversation. A leaked ID from another tenant would be mutated here and then logged under firstValidConversation.organizationId, which misattributes the operation as well.

Based on learnings: Enforce organization isolation for conversation write paths. After loading/fetching the conversation, verify that args.organizationId === conversation.organizationId before performing any inserts or patches.

Also applies to: 24-43, 64-83

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/convex/conversations/bulk_unarchive_conversations.ts`
around lines 8 - 16, In bulkUnarchiveConversations, validate organization
ownership for each loaded conversation: after fetching conversations (the
conversations array) check that args.organizationId ===
conversation.organizationId for every conversation (and before using
firstValidConversation for logging or before any ctx.db.patch/insert), and
reject or skip any conversation IDs that belong to a different org (return a
failed BulkOperationResult or omit them and report them as errors) so no
cross-org records are modified or misattributed; apply the same per-conversation
org check before any subsequent patches/inserts in the function.

Comment on lines +122 to +129
export const bulkArchiveConversations = mutationWithRLS({
args: {
conversationIds: v.array(v.id('conversations')),
},
returns: bulkOperationResultValidator,
handler: async (ctx, args) => {
return await ConversationsHelpers.bulkArchiveConversations(ctx, args);
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Restore org-scoped authorization on the new bulk mutations.

Both helpers patch whatever IDs are passed after ctx.db.get(id), but these mutation boundaries don't take organizationId, so they can't enforce the org match that other conversation write paths use. With mutationWithRLS, that means a caller can still archive/unarchive conversations from another organization they belong to, and the audit log will be attributed to whichever conversation is loaded first.

🔐 Suggested boundary fix
 export const bulkArchiveConversations = mutationWithRLS({
   args: {
+    organizationId: v.string(),
     conversationIds: v.array(v.id('conversations')),
   },
   returns: bulkOperationResultValidator,
   handler: async (ctx, args) => {
     return await ConversationsHelpers.bulkArchiveConversations(ctx, args);
   },
 });

 export const bulkUnarchiveConversations = mutationWithRLS({
   args: {
+    organizationId: v.string(),
     conversationIds: v.array(v.id('conversations')),
   },
   returns: bulkOperationResultValidator,
   handler: async (ctx, args) => {
     return await ConversationsHelpers.bulkUnarchiveConversations(ctx, args);
   },
 });

Then reject any loaded conversation whose conversation.organizationId !== args.organizationId before queuing a patch in both helper implementations.

Based on learnings: For conversation write paths, validate args.organizationId matches the loaded conversation.organizationId immediately after fetching the conversation and before any inserts/patches.

Also applies to: 153-160

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@services/platform/convex/conversations/mutations.ts` around lines 122 - 129,
The bulk conversation mutations lack org-scoped authorization: add an
organizationId arg to the mutation signatures (e.g., in the mutationWithRLS for
bulkArchiveConversations and the similar bulkUnarchive mutation) and, inside the
handler flow (and in the corresponding helper functions
ConversationsHelpers.bulkArchiveConversations and
ConversationsHelpers.bulkUnarchiveConversations), after fetching each
conversation via ctx.db.get(id) immediately validate that
conversation.organizationId === args.organizationId and reject (skip/throw) any
that do not match before queuing any patch or insert; ensure this check runs
before any database patch/insert or audit log enqueueing so no cross-org updates
or misattributed audit entries can occur.

Add bulk archive and unarchive operations for conversations with
corresponding backend mutations, frontend hooks, and toast feedback.

Redesign the inbox toolbar with icon-button bulk actions, a read/unread
radio filter, and selected-count display. Add contextual action banners
at the bottom of the conversation panel for closed, archived, and spam
states with reopen/unarchive/not-spam buttons. Implement thread
collapsing for long conversations and error state with retry.

Update conversation list row layout with tighter spacing, unread dot
indicator, and subject preview line.
@Israeltheminer Israeltheminer force-pushed the feat/bulk-archive-status-banners branch from a623649 to 1280dae Compare March 19, 2026 21:31
Add aria-label and role to unread dot for screen reader support.
Disable bulk mark-spam button since no handler is implemented yet.
Make filter dropdown trigger a focusable button for keyboard access.
…tus-banners

# Conflicts:
#	services/platform/messages/en.json
@Israeltheminer Israeltheminer force-pushed the feat/bulk-archive-status-banners branch from a746898 to 6ec9d1b Compare March 19, 2026 22:03
@Israeltheminer Israeltheminer merged commit 9f5dc53 into main Mar 19, 2026
17 checks passed
@Israeltheminer Israeltheminer deleted the feat/bulk-archive-status-banners branch March 19, 2026 22:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant