Skip to content

Commit 4b0b362

Browse files
committed
add server side batch invites for workspace
1 parent bd40384 commit 4b0b362

6 files changed

Lines changed: 527 additions & 311 deletions

File tree

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { getSession } from '@/lib/auth'
4+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
5+
import { normalizeEmail } from '@/lib/invitations/core'
6+
import {
7+
createWorkspaceInvitation,
8+
prepareWorkspaceInvitationContext,
9+
WorkspaceInvitationError,
10+
type WorkspaceInvitationResult,
11+
} from '@/lib/invitations/workspace-invitations'
12+
import { InvitationsNotAllowedError } from '@/ee/access-control/utils/permission-check'
13+
14+
export const dynamic = 'force-dynamic'
15+
16+
const logger = createLogger('WorkspaceInvitationBatchAPI')
17+
18+
interface BatchInvitationFailure {
19+
email: string
20+
error: string
21+
}
22+
23+
function isRecord(value: unknown): value is Record<string, unknown> {
24+
return typeof value === 'object' && value !== null
25+
}
26+
27+
function batchErrorResponse(error: unknown) {
28+
if (error instanceof WorkspaceInvitationError) {
29+
return NextResponse.json(
30+
{
31+
error: error.message,
32+
...(error.email ? { email: error.email } : {}),
33+
...(error.upgradeRequired !== undefined ? { upgradeRequired: error.upgradeRequired } : {}),
34+
},
35+
{ status: error.status }
36+
)
37+
}
38+
39+
if (error instanceof InvitationsNotAllowedError) {
40+
return NextResponse.json({ error: error.message }, { status: 403 })
41+
}
42+
43+
logger.error('Error creating workspace invitation batch:', error)
44+
return NextResponse.json({ error: 'Failed to create invitation batch' }, { status: 500 })
45+
}
46+
47+
export const POST = withRouteHandler(async (req: NextRequest) => {
48+
const session = await getSession()
49+
if (!session?.user?.id) {
50+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
51+
}
52+
53+
try {
54+
const body = (await req.json()) as { workspaceId?: unknown; invitations?: unknown }
55+
if (typeof body.workspaceId !== 'string' || body.workspaceId.length === 0) {
56+
return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 })
57+
}
58+
59+
if (!Array.isArray(body.invitations) || body.invitations.length === 0) {
60+
return NextResponse.json({ error: 'At least one invitation is required' }, { status: 400 })
61+
}
62+
63+
const context = await prepareWorkspaceInvitationContext({
64+
workspaceId: body.workspaceId,
65+
inviterId: session.user.id,
66+
inviterName: session.user.name || session.user.email || 'A user',
67+
inviterEmail: session.user.email,
68+
})
69+
70+
const successful: string[] = []
71+
const failed: BatchInvitationFailure[] = []
72+
const invitations: WorkspaceInvitationResult[] = []
73+
const seenEmails = new Set<string>()
74+
75+
for (const item of body.invitations) {
76+
if (!isRecord(item) || typeof item.email !== 'string') {
77+
return NextResponse.json(
78+
{ error: 'Each invitation must include an email' },
79+
{ status: 400 }
80+
)
81+
}
82+
83+
if (item.permission !== undefined && typeof item.permission !== 'string') {
84+
return NextResponse.json(
85+
{ error: 'Invitation permission must be a string when provided' },
86+
{ status: 400 }
87+
)
88+
}
89+
90+
const normalizedEmail = normalizeEmail(item.email)
91+
if (seenEmails.has(normalizedEmail)) {
92+
failed.push({
93+
email: normalizedEmail,
94+
error: `${normalizedEmail} appears more than once in this invitation batch`,
95+
})
96+
continue
97+
}
98+
seenEmails.add(normalizedEmail)
99+
100+
try {
101+
const invitation = await createWorkspaceInvitation({
102+
context,
103+
email: item.email,
104+
permission: item.permission,
105+
request: req,
106+
})
107+
successful.push(invitation.email)
108+
invitations.push(invitation)
109+
} catch (error) {
110+
if (error instanceof WorkspaceInvitationError) {
111+
failed.push({ email: error.email ?? normalizedEmail, error: error.message })
112+
continue
113+
}
114+
115+
logger.error('Unexpected workspace invitation batch item failure:', {
116+
email: normalizedEmail,
117+
error,
118+
})
119+
throw error
120+
}
121+
}
122+
123+
return NextResponse.json({
124+
success: failed.length === 0,
125+
successful,
126+
failed,
127+
invitations,
128+
})
129+
} catch (error) {
130+
return batchErrorResponse(error)
131+
}
132+
})

apps/sim/app/api/workspaces/invitations/route.test.ts

Lines changed: 59 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,9 @@ const mockGetSession = authMockFns.mockGetSession
108108
const mockGetWorkspaceWithOwner = permissionsMockFns.mockGetWorkspaceWithOwner
109109

110110
import { UPGRADE_TO_INVITE_REASON } from '@/lib/workspaces/policy-constants'
111-
import { POST } from '@/app/api/workspaces/invitations/route'
111+
import { POST } from '@/app/api/workspaces/invitations/batch/route'
112112

113-
describe('POST /api/workspaces/invitations', () => {
113+
describe('POST /api/workspaces/invitations/batch', () => {
114114
beforeEach(() => {
115115
vi.clearAllMocks()
116116
mockDbResults.value = []
@@ -169,8 +169,7 @@ describe('POST /api/workspaces/invitations', () => {
169169

170170
const request = createMockRequest('POST', {
171171
workspaceId: 'workspace-1',
172-
email: 'new@example.com',
173-
permission: 'read',
172+
invitations: [{ email: 'new@example.com', permission: 'read' }],
174173
})
175174

176175
const response = await POST(request)
@@ -201,8 +200,7 @@ describe('POST /api/workspaces/invitations', () => {
201200

202201
const request = createMockRequest('POST', {
203202
workspaceId: 'workspace-1',
204-
email: 'new@example.com',
205-
permission: 'read',
203+
invitations: [{ email: 'new@example.com', permission: 'read' }],
206204
})
207205

208206
const response = await POST(request)
@@ -213,7 +211,7 @@ describe('POST /api/workspaces/invitations', () => {
213211
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
214212
})
215213

216-
it('rejects org-owned invites when the organization has no available seats', async () => {
214+
it('reports org-owned invites as failed when the organization has no available seats', async () => {
217215
mockGetWorkspaceWithOwner.mockResolvedValueOnce({
218216
id: 'workspace-1',
219217
name: 'Org Workspace',
@@ -240,15 +238,20 @@ describe('POST /api/workspaces/invitations', () => {
240238

241239
const request = createMockRequest('POST', {
242240
workspaceId: 'workspace-1',
243-
email: 'new@example.com',
244-
permission: 'read',
241+
invitations: [{ email: 'new@example.com', permission: 'read' }],
245242
})
246243

247244
const response = await POST(request)
248245
const data = await response.json()
249246

250-
expect(response.status).toBe(400)
251-
expect(data.error).toContain('No available seats')
247+
expect(response.status).toBe(200)
248+
expect(data.success).toBe(false)
249+
expect(data.failed).toEqual([
250+
{
251+
email: 'new@example.com',
252+
error: 'No available seats. Currently using 5 of 5 seats.',
253+
},
254+
])
252255
expect(mockValidateSeatAvailability).toHaveBeenCalledWith('org-1', 1)
253256
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
254257
})
@@ -281,16 +284,15 @@ describe('POST /api/workspaces/invitations', () => {
281284

282285
const request = createMockRequest('POST', {
283286
workspaceId: 'workspace-1',
284-
email: 'new@example.com',
285-
permission: 'read',
287+
invitations: [{ email: 'new@example.com', permission: 'read' }],
286288
})
287289

288290
const response = await POST(request)
289291
const data = await response.json()
290292

291293
expect(response.status).toBe(200)
292294
expect(data.success).toBe(true)
293-
expect(data.invitation.membershipIntent).toBe('external')
295+
expect(data.invitations[0].membershipIntent).toBe('external')
294296
expect(mockValidateSeatAvailability).not.toHaveBeenCalled()
295297
expect(mockCreatePendingInvitation).toHaveBeenCalledWith(
296298
expect.objectContaining({
@@ -316,8 +318,7 @@ describe('POST /api/workspaces/invitations', () => {
316318

317319
const request = createMockRequest('POST', {
318320
workspaceId: 'workspace-1',
319-
email: 'new@example.com',
320-
permission: 'write',
321+
invitations: [{ email: 'new@example.com', permission: 'write' }],
321322
})
322323

323324
const response = await POST(request)
@@ -337,6 +338,40 @@ describe('POST /api/workspaces/invitations', () => {
337338
expect(mockValidateSeatAvailability).not.toHaveBeenCalled()
338339
})
339340

341+
it('creates multiple workspace invitations in one batch request', async () => {
342+
mockDbResults.value = [[{ permissionType: 'admin' }], [], []]
343+
mockCreatePendingInvitation
344+
.mockResolvedValueOnce({
345+
invitationId: 'inv-1',
346+
token: 'tok-1',
347+
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
348+
})
349+
.mockResolvedValueOnce({
350+
invitationId: 'inv-2',
351+
token: 'tok-2',
352+
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
353+
})
354+
355+
const request = createMockRequest('POST', {
356+
workspaceId: 'workspace-1',
357+
invitations: [
358+
{ email: 'first@example.com', permission: 'read' },
359+
{ email: 'second@example.com', permission: 'write' },
360+
],
361+
})
362+
363+
const response = await POST(request)
364+
const data = await response.json()
365+
366+
expect(response.status).toBe(200)
367+
expect(data.success).toBe(true)
368+
expect(data.successful).toEqual(['first@example.com', 'second@example.com'])
369+
expect(data.failed).toEqual([])
370+
expect(data.invitations).toHaveLength(2)
371+
expect(mockCreatePendingInvitation).toHaveBeenCalledTimes(2)
372+
expect(mockSendInvitationEmail).toHaveBeenCalledTimes(2)
373+
})
374+
340375
it('rolls back the unified invitation when email delivery fails', async () => {
341376
mockGetWorkspaceWithOwner.mockResolvedValueOnce({
342377
id: 'workspace-1',
@@ -354,13 +389,18 @@ describe('POST /api/workspaces/invitations', () => {
354389

355390
const request = createMockRequest('POST', {
356391
workspaceId: 'workspace-1',
357-
email: 'new@example.com',
358-
permission: 'read',
392+
invitations: [{ email: 'new@example.com', permission: 'read' }],
359393
})
360394

361395
const response = await POST(request)
362396

363-
expect(response.status).toBe(502)
397+
expect(response.status).toBe(200)
398+
await expect(response.json()).resolves.toEqual(
399+
expect.objectContaining({
400+
success: false,
401+
failed: [{ email: 'new@example.com', error: 'mailer unavailable' }],
402+
})
403+
)
364404
expect(mockCancelPendingInvitation).toHaveBeenCalledWith('inv-1')
365405
})
366406
})

0 commit comments

Comments
 (0)