@@ -79,6 +79,15 @@ vi.mock('@sim/db/schema', () => ({
7979 organizationId : 'workspace.organizationId' ,
8080 workspaceMode : 'workspace.workspaceMode' ,
8181 } ,
82+ permissions : {
83+ entityId : 'permissions.entityId' ,
84+ entityType : 'permissions.entityType' ,
85+ userId : 'permissions.userId' ,
86+ } ,
87+ invitationWorkspaceGrant : {
88+ invitationId : 'invitationWorkspaceGrant.invitationId' ,
89+ workspaceId : 'invitationWorkspaceGrant.workspaceId' ,
90+ } ,
8291} ) )
8392
8493vi . mock ( 'drizzle-orm' , ( ) => ( {
@@ -182,6 +191,175 @@ describe('POST /api/organizations/[id]/invitations', () => {
182191 expect ( mockCancelPendingInvitation ) . not . toHaveBeenCalled ( )
183192 } )
184193
194+ it ( 'sends a workspace invitation to an existing member for selected workspaces they lack' , async ( ) => {
195+ mockGetSession . mockResolvedValue (
196+ createSession ( { userId : 'user-1' , email : 'owner@example.com' , name : 'Owner' } )
197+ )
198+ mockDbState . selectResults = [
199+ [ { role : 'owner' } ] ,
200+ [ { name : 'Org One' } ] ,
201+ [ { id : 'ws-1' , organizationId : 'org-1' , workspaceMode : 'organization' } ] ,
202+ [ { id : 'ws-2' , organizationId : 'org-1' , workspaceMode : 'organization' } ] ,
203+ [ { userId : 'user-2' , userEmail : 'member@example.com' } ] ,
204+ [ ] ,
205+ [ { userId : 'user-2' , workspaceId : 'ws-1' } ] ,
206+ [ ] ,
207+ [ { name : 'Owner' , email : 'owner@example.com' } ] ,
208+ ]
209+
210+ const response = await POST (
211+ createMockRequest (
212+ 'POST' ,
213+ {
214+ emails : [ 'member@example.com' ] ,
215+ workspaceInvitations : [
216+ { workspaceId : 'ws-1' , permission : 'write' } ,
217+ { workspaceId : 'ws-2' , permission : 'write' } ,
218+ ] ,
219+ } ,
220+ { } ,
221+ 'http://localhost/api/organizations/org-1/invitations?batch=true'
222+ ) ,
223+ { params : Promise . resolve ( { id : 'org-1' } ) }
224+ )
225+
226+ expect ( response . status ) . toBe ( 200 )
227+ expect ( mockCreatePendingInvitation ) . toHaveBeenCalledTimes ( 1 )
228+ expect ( mockCreatePendingInvitation ) . toHaveBeenCalledWith (
229+ expect . objectContaining ( {
230+ kind : 'workspace' ,
231+ email : 'member@example.com' ,
232+ 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' } ] ,
242+ } )
243+ )
244+
245+ 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 . existingMembers ) . toEqual ( [ ] )
249+ } )
250+
251+ it ( 'returns 400 when an existing member already has access to every selected workspace' , async ( ) => {
252+ mockGetSession . mockResolvedValue (
253+ createSession ( { userId : 'user-1' , email : 'owner@example.com' , name : 'Owner' } )
254+ )
255+ mockDbState . selectResults = [
256+ [ { role : 'owner' } ] ,
257+ [ { name : 'Org One' } ] ,
258+ [ { id : 'ws-1' , organizationId : 'org-1' , workspaceMode : 'organization' } ] ,
259+ [ { userId : 'user-2' , userEmail : 'member@example.com' } ] ,
260+ [ ] ,
261+ [ { userId : 'user-2' , workspaceId : 'ws-1' } ] ,
262+ [ ] ,
263+ ]
264+
265+ const response = await POST (
266+ createMockRequest (
267+ 'POST' ,
268+ {
269+ emails : [ 'member@example.com' ] ,
270+ workspaceInvitations : [ { workspaceId : 'ws-1' , permission : 'write' } ] ,
271+ } ,
272+ { } ,
273+ 'http://localhost/api/organizations/org-1/invitations?batch=true'
274+ ) ,
275+ { params : Promise . resolve ( { id : 'org-1' } ) }
276+ )
277+
278+ expect ( response . status ) . toBe ( 400 )
279+ const body = await response . json ( )
280+ expect ( body . error ) . toContain ( 'already has access' )
281+ expect ( mockCreatePendingInvitation ) . not . toHaveBeenCalled ( )
282+ } )
283+
284+ it ( 'invites new emails to the organization and existing members to workspaces in one batch' , async ( ) => {
285+ mockGetSession . mockResolvedValue (
286+ createSession ( { userId : 'user-1' , email : 'owner@example.com' , name : 'Owner' } )
287+ )
288+ mockDbState . selectResults = [
289+ [ { role : 'owner' } ] ,
290+ [ { name : 'Org One' } ] ,
291+ [ { id : 'ws-1' , organizationId : 'org-1' , workspaceMode : 'organization' } ] ,
292+ [ { userId : 'user-2' , userEmail : 'member@example.com' } ] ,
293+ [ ] ,
294+ [ ] ,
295+ [ ] ,
296+ [ { name : 'Owner' , email : 'owner@example.com' } ] ,
297+ ]
298+
299+ const response = await POST (
300+ createMockRequest (
301+ 'POST' ,
302+ {
303+ emails : [ 'new@example.com' , 'member@example.com' ] ,
304+ workspaceInvitations : [ { workspaceId : 'ws-1' , permission : 'read' } ] ,
305+ } ,
306+ { } ,
307+ 'http://localhost/api/organizations/org-1/invitations?batch=true'
308+ ) ,
309+ { params : Promise . resolve ( { id : 'org-1' } ) }
310+ )
311+
312+ expect ( response . status ) . toBe ( 200 )
313+ expect ( mockCreatePendingInvitation ) . toHaveBeenCalledTimes ( 2 )
314+ expect ( mockCreatePendingInvitation ) . toHaveBeenCalledWith (
315+ expect . objectContaining ( {
316+ kind : 'organization' ,
317+ email : 'new@example.com' ,
318+ grants : [ { workspaceId : 'ws-1' , permission : 'read' } ] ,
319+ } )
320+ )
321+ expect ( mockCreatePendingInvitation ) . toHaveBeenCalledWith (
322+ expect . objectContaining ( {
323+ kind : 'workspace' ,
324+ email : 'member@example.com' ,
325+ grants : [ { workspaceId : 'ws-1' , permission : 'read' } ] ,
326+ } )
327+ )
328+
329+ const body = await response . json ( )
330+ expect ( body . data . invitationsSent ) . toBe ( 2 )
331+ expect ( body . data . invitedEmails ) . toEqual ( [ 'new@example.com' , 'member@example.com' ] )
332+ } )
333+
334+ it ( 'still rejects existing members on the non-batch organization invite path' , async ( ) => {
335+ mockGetSession . mockResolvedValue (
336+ createSession ( { userId : 'user-1' , email : 'owner@example.com' , name : 'Owner' } )
337+ )
338+ mockDbState . selectResults = [
339+ [ { role : 'owner' } ] ,
340+ [ { name : 'Org One' } ] ,
341+ [ { userId : 'user-2' , userEmail : 'member@example.com' } ] ,
342+ [ ] ,
343+ ]
344+
345+ const response = await POST (
346+ createMockRequest (
347+ 'POST' ,
348+ { emails : [ 'member@example.com' ] } ,
349+ { } ,
350+ 'http://localhost/api/organizations/org-1/invitations'
351+ ) ,
352+ { params : Promise . resolve ( { id : 'org-1' } ) }
353+ )
354+
355+ expect ( response . status ) . toBe ( 400 )
356+ const body = await response . json ( )
357+ expect ( body . error ) . toBe (
358+ 'Failed to send invitation. User is already a part of the organization.'
359+ )
360+ expect ( mockCreatePendingInvitation ) . not . toHaveBeenCalled ( )
361+ } )
362+
185363 it ( 'rolls back the pending invitation when email delivery fails' , async ( ) => {
186364 mockGetSession . mockResolvedValue (
187365 createSession ( { userId : 'user-1' , email : 'owner@example.com' , name : 'Owner' } )
0 commit comments