Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@ vi.mock('@/hooks/queries/workspace', () => ({
},
}))

import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal/invite-modal'
import {
buildInviteFailureMessage,
InviteModal,
} from '@/app/workspace/[workspaceId]/components/invite-modal/invite-modal'

let container: HTMLDivElement
let root: Root
Expand Down Expand Up @@ -159,3 +162,70 @@ describe('InviteModal organization billing isolation', () => {
expect(mockUseAdminWorkspaces).toHaveBeenCalledWith('user-1', undefined, { enabled: false })
})
})

describe('buildInviteFailureMessage', () => {
const notPaid = (email: string) =>
`${email} is not on a paid Sim plan, so they cannot be added as an external collaborator. Invite them as a Member or Admin instead — that adds a seat.`

it('returns the reason alone for a single failure, which already names the address', () => {
expect(buildInviteFailureMessage([{ email: 'a@x.com', error: notPaid('a@x.com') }], 3)).toBe(
notPaid('a@x.com')
)
})

it('names every address when a whole batch is rejected for the same cause', () => {
const message = buildInviteFailureMessage(
[
{ email: 'a@x.com', error: notPaid('a@x.com') },
{ email: 'b@x.com', error: notPaid('b@x.com') },
{ email: 'c@x.com', error: notPaid('c@x.com') },
],
3
)

expect(message).toContain('None of the 3 invitations could be sent.')
expect(message).toContain('a@x.com')
expect(message).toContain('b@x.com')
expect(message).toContain('c@x.com')
})

it('reports the denominator when only some of the batch failed', () => {
const message = buildInviteFailureMessage(
[
{ email: 'a@x.com', error: notPaid('a@x.com') },
{ email: 'b@x.com', error: 'b@x.com has already been invited to this workspace' },
],
5
)

expect(message).toContain('2 of 5 invitations could not be sent.')
expect(message).toContain('b@x.com has already been invited to this workspace')
})

it('collapses identical reasons instead of repeating them', () => {
const message = buildInviteFailureMessage(
[
{ email: 'a@x.com', error: 'Something went wrong' },
{ email: 'b@x.com', error: 'Something went wrong' },
],
2
)

expect(message).toBe('None of the 2 invitations could be sent. Something went wrong')
})

it('counts the reasons it cannot list rather than dropping them silently', () => {
const message = buildInviteFailureMessage(
['a', 'b', 'c', 'd', 'e'].map((name) => ({
email: `${name}@x.com`,
error: notPaid(`${name}@x.com`),
})),
5
)

expect(message).toContain('None of the 5 invitations could be sent.')
expect(message).toContain('c@x.com')
expect(message).not.toContain('d@x.com')
expect(message).toContain('And 2 more.')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
toast,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import type { BatchInvitationResult } from '@/lib/api/contracts/invitations'
import { useSession } from '@/lib/auth/auth-client'
import { isEnterprise } from '@/lib/billing/plan-helpers'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
Expand Down Expand Up @@ -48,6 +49,40 @@ const MEMBERSHIP_HINTS: Record<Membership, string> = {

const EMPTY_WORKSPACE_IDS: string[] = []

/** Distinct failure reasons listed in full before the remainder is counted instead. */
const MAX_LISTED_FAILURE_REASONS = 3

/**
* Builds the submit error for a batch where some or all invitations failed.
*
* The server rejects per email and every reason names its own address, so the
* reasons are listed rather than collapsed to the first one: inviting several
* people who are all ineligible for the chosen membership is the common
* multi-failure case, and reporting one of them hides who else needs attention.
* Identical reasons are deduplicated, and beyond
* {@link MAX_LISTED_FAILURE_REASONS} the tail is counted rather than listed so a
* large batch cannot bury the modal in near-identical sentences.
*/
export function buildInviteFailureMessage(
failures: BatchInvitationResult['failed'],
attemptedCount: number
): string {
if (failures.length === 1) return failures[0].error

const headline =
failures.length >= attemptedCount
? `None of the ${failures.length} invitations could be sent.`
: `${failures.length} of ${attemptedCount} invitations could not be sent.`

const reasons = [...new Set(failures.map((failure) => failure.error))]
const listed = reasons.slice(0, MAX_LISTED_FAILURE_REASONS)
const remaining = reasons.length - listed.length

return [headline, ...listed, remaining > 0 ? `And ${remaining} more.` : null]
.filter(Boolean)
.join(' ')
}

interface InviteModalProps {
open: boolean
onOpenChange: (open: boolean) => void
Expand Down Expand Up @@ -228,13 +263,9 @@ export function InviteModal({
}

if (result.failed.length > 0) {
// Keep the failed addresses in the field, with the reason, for retry.
// Keep the failed addresses in the field, with the reasons, for retry.
setEmails(result.failed.map((failure) => failure.email))
setErrorMessage(
result.failed.length === 1
? result.failed[0].error
: `${result.failed.length} invitations failed. ${result.failed[0].error}`
)
setErrorMessage(buildInviteFailureMessage(result.failed, emails.length))
return
}

Expand Down Expand Up @@ -314,7 +345,7 @@ export function InviteModal({
{isOrganizationInvite && (
<ChipModalField
type='dropdown'
title='Membership'
title='Organization membership'
options={membershipOptions}
value={membership}
placeholder='Select membership'
Expand Down
Loading