@@ -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
2729function 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+
118124vi . 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,30 +231,111 @@ 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
254+ it ( 'reports a partially-failed member only as added, never in both buckets' , async ( ) => {
255+ mockGetSession . mockResolvedValue (
256+ createSession ( { userId : 'user-1' , email : 'owner@example.com' , name : 'Owner' } )
257+ )
258+ // First grant succeeds, second throws (e.g. transient DB error).
259+ mockGrantWorkspaceAccessDirectly
260+ . mockResolvedValueOnce ( { outcome : 'added' , permission : 'write' } )
261+ . mockRejectedValueOnce ( new Error ( 'db blip' ) )
262+ mockDbState . selectResults = [
263+ [ { role : 'owner' } ] ,
264+ [ { name : 'Org One' } ] ,
265+ [ { id : 'ws-1' , name : 'Workspace 1' , organizationId : 'org-1' , workspaceMode : 'organization' } ] ,
266+ [ { id : 'ws-2' , name : 'Workspace 2' , organizationId : 'org-1' , workspaceMode : 'organization' } ] ,
267+ [ { userId : 'user-2' , userEmail : 'member@example.com' } ] ,
268+ [ ] ,
269+ [ ] ,
270+ [ ] ,
271+ [ { name : 'Owner' , email : 'owner@example.com' } ] ,
272+ ]
273+
274+ const response = await POST (
275+ createMockRequest (
276+ 'POST' ,
277+ {
278+ emails : [ 'member@example.com' ] ,
279+ workspaceInvitations : [
280+ { workspaceId : 'ws-1' , permission : 'write' } ,
281+ { workspaceId : 'ws-2' , permission : 'write' } ,
282+ ] ,
283+ } ,
284+ { } ,
285+ 'http://localhost/api/organizations/org-1/invitations?batch=true'
286+ ) ,
287+ { params : Promise . resolve ( { id : 'org-1' } ) }
288+ )
289+
290+ expect ( response . status ) . toBe ( 200 )
291+ expect ( mockGrantWorkspaceAccessDirectly ) . toHaveBeenCalledTimes ( 2 )
292+ const body = await response . json ( )
293+ expect ( body . data . directlyAdded ) . toEqual ( [ 'member@example.com' ] )
294+ expect ( body . data . failedInvitations ) . toEqual ( [ ] )
295+ } )
296+
297+ it ( 'returns 207 with both successes and failures when one member is added and another fails' , async ( ) => {
298+ mockGetSession . mockResolvedValue (
299+ createSession ( { userId : 'user-1' , email : 'owner@example.com' , name : 'Owner' } )
300+ )
301+ mockGrantWorkspaceAccessDirectly
302+ . mockResolvedValueOnce ( { outcome : 'added' , permission : 'write' } )
303+ . mockRejectedValueOnce ( new Error ( 'db blip' ) )
304+ mockDbState . selectResults = [
305+ [ { role : 'owner' } ] ,
306+ [ { name : 'Org One' } ] ,
307+ [ { id : 'ws-1' , name : 'Workspace 1' , organizationId : 'org-1' , workspaceMode : 'organization' } ] ,
308+ [
309+ { userId : 'user-a' , userEmail : 'a@example.com' } ,
310+ { userId : 'user-b' , userEmail : 'b@example.com' } ,
311+ ] ,
312+ [ ] ,
313+ [ ] ,
314+ [ ] ,
315+ [ { name : 'Owner' , email : 'owner@example.com' } ] ,
316+ ]
317+
318+ const response = await POST (
319+ createMockRequest (
320+ 'POST' ,
321+ {
322+ emails : [ 'a@example.com' , 'b@example.com' ] ,
323+ workspaceInvitations : [ { workspaceId : 'ws-1' , permission : 'write' } ] ,
324+ } ,
325+ { } ,
326+ 'http://localhost/api/organizations/org-1/invitations?batch=true'
327+ ) ,
328+ { params : Promise . resolve ( { id : 'org-1' } ) }
329+ )
330+
331+ expect ( response . status ) . toBe ( 207 )
332+ const body = await response . json ( )
333+ expect ( body . success ) . toBe ( false )
334+ expect ( body . data . directlyAdded ) . toEqual ( [ 'a@example.com' ] )
335+ expect ( body . data . directlyAddedCount ) . toBe ( 1 )
336+ expect ( body . data . failedInvitations ) . toEqual ( [ { email : 'b@example.com' , error : 'db blip' } ] )
337+ } )
338+
251339 it ( 'returns 400 when an existing member already has access to every selected workspace' , async ( ) => {
252340 mockGetSession . mockResolvedValue (
253341 createSession ( { userId : 'user-1' , email : 'owner@example.com' , name : 'Owner' } )
@@ -281,14 +369,14 @@ describe('POST /api/organizations/[id]/invitations', () => {
281369 expect ( mockCreatePendingInvitation ) . not . toHaveBeenCalled ( )
282370 } )
283371
284- it ( 'invites new emails to the organization and existing members to workspaces in one batch' , async ( ) => {
372+ it ( 'invites new emails to the organization and adds existing members to workspaces in one batch' , async ( ) => {
285373 mockGetSession . mockResolvedValue (
286374 createSession ( { userId : 'user-1' , email : 'owner@example.com' , name : 'Owner' } )
287375 )
288376 mockDbState . selectResults = [
289377 [ { role : 'owner' } ] ,
290378 [ { name : 'Org One' } ] ,
291- [ { id : 'ws-1' , organizationId : 'org-1' , workspaceMode : 'organization' } ] ,
379+ [ { id : 'ws-1' , name : 'Workspace 1' , organizationId : 'org-1' , workspaceMode : 'organization' } ] ,
292380 [ { userId : 'user-2' , userEmail : 'member@example.com' } ] ,
293381 [ ] ,
294382 [ ] ,
@@ -310,25 +398,29 @@ describe('POST /api/organizations/[id]/invitations', () => {
310398 )
311399
312400 expect ( response . status ) . toBe ( 200 )
313- expect ( mockCreatePendingInvitation ) . toHaveBeenCalledTimes ( 2 )
401+ expect ( mockCreatePendingInvitation ) . toHaveBeenCalledTimes ( 1 )
314402 expect ( mockCreatePendingInvitation ) . toHaveBeenCalledWith (
315403 expect . objectContaining ( {
316404 kind : 'organization' ,
317405 email : 'new@example.com' ,
318406 grants : [ { workspaceId : 'ws-1' , permission : 'read' } ] ,
319407 } )
320408 )
321- expect ( mockCreatePendingInvitation ) . toHaveBeenCalledWith (
409+ expect ( mockGrantWorkspaceAccessDirectly ) . toHaveBeenCalledTimes ( 1 )
410+ expect ( mockGrantWorkspaceAccessDirectly ) . toHaveBeenCalledWith (
322411 expect . objectContaining ( {
323- kind : 'workspace ' ,
412+ userId : 'user-2 ' ,
324413 email : 'member@example.com' ,
325- grants : [ { workspaceId : 'ws-1' , permission : 'read' } ] ,
414+ workspaceId : 'ws-1' ,
415+ permission : 'read' ,
326416 } )
327417 )
328418
329419 const body = await response . json ( )
330- expect ( body . data . invitationsSent ) . toBe ( 2 )
331- expect ( body . data . invitedEmails ) . toEqual ( [ 'new@example.com' , 'member@example.com' ] )
420+ expect ( body . data . invitationsSent ) . toBe ( 1 )
421+ expect ( body . data . invitedEmails ) . toEqual ( [ 'new@example.com' ] )
422+ expect ( body . data . directlyAdded ) . toEqual ( [ 'member@example.com' ] )
423+ expect ( body . data . directlyAddedCount ) . toBe ( 1 )
332424 } )
333425
334426 it ( 'still rejects existing members on the non-batch organization invite path' , async ( ) => {
0 commit comments