Skip to content

Commit cf65e12

Browse files
committed
feat(workspaces): auto-add without invite if part of organization
1 parent 58312a1 commit cf65e12

25 files changed

Lines changed: 1082 additions & 134 deletions

File tree

apps/sim/app/api/organizations/[id]/invitations/route.test.ts

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const {
1212
mockCreatePendingInvitation,
1313
mockSendInvitationEmail,
1414
mockCancelPendingInvitation,
15+
mockGrantWorkspaceAccessDirectly,
1516
} = vi.hoisted(() => ({
1617
mockDbState: {
1718
selectResults: [] as any[],
@@ -22,6 +23,7 @@ const {
2223
mockCreatePendingInvitation: vi.fn(),
2324
mockSendInvitationEmail: vi.fn(),
2425
mockCancelPendingInvitation: vi.fn(),
26+
mockGrantWorkspaceAccessDirectly: vi.fn(),
2527
}))
2628

2729
function createSelectChain() {
@@ -115,6 +117,10 @@ vi.mock('@/lib/invitations/send', () => ({
115117
cancelPendingInvitation: mockCancelPendingInvitation,
116118
}))
117119

120+
vi.mock('@/lib/invitations/direct-grant', () => ({
121+
grantWorkspaceAccessDirectly: mockGrantWorkspaceAccessDirectly,
122+
}))
123+
118124
vi.mock('@/lib/messaging/email/validation', () => ({
119125
quickValidateEmail: vi.fn((email: string) => ({ isValid: email.includes('@') })),
120126
}))
@@ -151,6 +157,7 @@ describe('POST /api/organizations/[id]/invitations', () => {
151157
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
152158
})
153159
mockSendInvitationEmail.mockResolvedValue({ success: true })
160+
mockGrantWorkspaceAccessDirectly.mockResolvedValue({ outcome: 'added', permission: 'write' })
154161
})
155162

156163
it('creates a unified invitation and sends a single email', async () => {
@@ -191,15 +198,15 @@ describe('POST /api/organizations/[id]/invitations', () => {
191198
expect(mockCancelPendingInvitation).not.toHaveBeenCalled()
192199
})
193200

194-
it('sends a workspace invitation to an existing member for selected workspaces they lack', async () => {
201+
it('adds an existing member directly to selected workspaces they lack (no invitation/email)', async () => {
195202
mockGetSession.mockResolvedValue(
196203
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
197204
)
198205
mockDbState.selectResults = [
199206
[{ role: 'owner' }],
200207
[{ name: 'Org One' }],
201-
[{ id: 'ws-1', organizationId: 'org-1', workspaceMode: 'organization' }],
202-
[{ id: 'ws-2', organizationId: 'org-1', workspaceMode: 'organization' }],
208+
[{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }],
209+
[{ id: 'ws-2', name: 'Workspace 2', organizationId: 'org-1', workspaceMode: 'organization' }],
203210
[{ userId: 'user-2', userEmail: 'member@example.com' }],
204211
[],
205212
[{ userId: 'user-2', workspaceId: 'ws-1' }],
@@ -224,27 +231,23 @@ describe('POST /api/organizations/[id]/invitations', () => {
224231
)
225232

226233
expect(response.status).toBe(200)
227-
expect(mockCreatePendingInvitation).toHaveBeenCalledTimes(1)
228-
expect(mockCreatePendingInvitation).toHaveBeenCalledWith(
234+
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
235+
expect(mockSendInvitationEmail).not.toHaveBeenCalled()
236+
expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledTimes(1)
237+
expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledWith(
229238
expect.objectContaining({
230-
kind: 'workspace',
239+
userId: 'user-2',
231240
email: 'member@example.com',
241+
workspaceId: 'ws-2',
242+
permission: 'write',
232243
organizationId: 'org-1',
233-
membershipIntent: 'internal',
234-
grants: [{ workspaceId: 'ws-2', permission: 'write' }],
235-
})
236-
)
237-
expect(mockSendInvitationEmail).toHaveBeenCalledWith(
238-
expect.objectContaining({
239-
kind: 'workspace',
240-
email: 'member@example.com',
241-
grants: [{ workspaceId: 'ws-2', permission: 'write' }],
242244
})
243245
)
244246

245247
const body = await response.json()
246-
expect(body.data.invitationsSent).toBe(1)
247-
expect(body.data.invitedEmails).toEqual(['member@example.com'])
248+
expect(body.data.invitationsSent).toBe(0)
249+
expect(body.data.directlyAdded).toEqual(['member@example.com'])
250+
expect(body.data.directlyAddedCount).toBe(1)
248251
expect(body.data.existingMembers).toEqual([])
249252
})
250253

@@ -281,14 +284,14 @@ describe('POST /api/organizations/[id]/invitations', () => {
281284
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
282285
})
283286

284-
it('invites new emails to the organization and existing members to workspaces in one batch', async () => {
287+
it('invites new emails to the organization and adds existing members to workspaces in one batch', async () => {
285288
mockGetSession.mockResolvedValue(
286289
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
287290
)
288291
mockDbState.selectResults = [
289292
[{ role: 'owner' }],
290293
[{ name: 'Org One' }],
291-
[{ id: 'ws-1', organizationId: 'org-1', workspaceMode: 'organization' }],
294+
[{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }],
292295
[{ userId: 'user-2', userEmail: 'member@example.com' }],
293296
[],
294297
[],
@@ -310,25 +313,29 @@ describe('POST /api/organizations/[id]/invitations', () => {
310313
)
311314

312315
expect(response.status).toBe(200)
313-
expect(mockCreatePendingInvitation).toHaveBeenCalledTimes(2)
316+
expect(mockCreatePendingInvitation).toHaveBeenCalledTimes(1)
314317
expect(mockCreatePendingInvitation).toHaveBeenCalledWith(
315318
expect.objectContaining({
316319
kind: 'organization',
317320
email: 'new@example.com',
318321
grants: [{ workspaceId: 'ws-1', permission: 'read' }],
319322
})
320323
)
321-
expect(mockCreatePendingInvitation).toHaveBeenCalledWith(
324+
expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledTimes(1)
325+
expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledWith(
322326
expect.objectContaining({
323-
kind: 'workspace',
327+
userId: 'user-2',
324328
email: 'member@example.com',
325-
grants: [{ workspaceId: 'ws-1', permission: 'read' }],
329+
workspaceId: 'ws-1',
330+
permission: 'read',
326331
})
327332
)
328333

329334
const body = await response.json()
330-
expect(body.data.invitationsSent).toBe(2)
331-
expect(body.data.invitedEmails).toEqual(['new@example.com', 'member@example.com'])
335+
expect(body.data.invitationsSent).toBe(1)
336+
expect(body.data.invitedEmails).toEqual(['new@example.com'])
337+
expect(body.data.directlyAdded).toEqual(['member@example.com'])
338+
expect(body.data.directlyAddedCount).toBe(1)
332339
})
333340

334341
it('still rejects existing members on the non-batch organization invite path', async () => {

0 commit comments

Comments
 (0)