chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { auditMock, createMockRequest, createSession, loggerMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockDbState,
|
||||
mockGetSession,
|
||||
mockValidateInvitationsAllowed,
|
||||
mockValidateSeatAvailability,
|
||||
mockCreatePendingInvitation,
|
||||
mockSendInvitationEmail,
|
||||
mockCancelPendingInvitation,
|
||||
mockGrantWorkspaceAccessDirectly,
|
||||
} = vi.hoisted(() => ({
|
||||
mockDbState: {
|
||||
selectResults: [] as any[],
|
||||
},
|
||||
mockGetSession: vi.fn(),
|
||||
mockValidateInvitationsAllowed: vi.fn(),
|
||||
mockValidateSeatAvailability: vi.fn(),
|
||||
mockCreatePendingInvitation: vi.fn(),
|
||||
mockSendInvitationEmail: vi.fn(),
|
||||
mockCancelPendingInvitation: vi.fn(),
|
||||
mockGrantWorkspaceAccessDirectly: vi.fn(),
|
||||
}))
|
||||
|
||||
function createSelectChain() {
|
||||
const chain: any = {}
|
||||
chain.from = vi.fn().mockReturnValue(chain)
|
||||
chain.innerJoin = vi.fn().mockReturnValue(chain)
|
||||
chain.leftJoin = vi.fn().mockReturnValue(chain)
|
||||
chain.where = vi.fn().mockReturnValue(chain)
|
||||
chain.orderBy = vi.fn().mockReturnValue(chain)
|
||||
chain.limit = vi
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve(mockDbState.selectResults.shift() ?? []))
|
||||
chain.then = vi.fn().mockImplementation((callback: (rows: any[]) => unknown) => {
|
||||
const rows = mockDbState.selectResults.shift() ?? []
|
||||
return Promise.resolve(callback(rows))
|
||||
})
|
||||
return chain
|
||||
}
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn().mockImplementation(() => createSelectChain()),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db/schema', () => ({
|
||||
invitation: {
|
||||
id: 'invitation.id',
|
||||
organizationId: 'invitation.organizationId',
|
||||
status: 'invitation.status',
|
||||
email: 'invitation.email',
|
||||
kind: 'invitation.kind',
|
||||
role: 'invitation.role',
|
||||
inviterId: 'invitation.inviterId',
|
||||
expiresAt: 'invitation.expiresAt',
|
||||
createdAt: 'invitation.createdAt',
|
||||
},
|
||||
member: {
|
||||
organizationId: 'member.organizationId',
|
||||
userId: 'member.userId',
|
||||
role: 'member.role',
|
||||
},
|
||||
organization: {
|
||||
id: 'organization.id',
|
||||
name: 'organization.name',
|
||||
},
|
||||
user: {
|
||||
id: 'user.id',
|
||||
name: 'user.name',
|
||||
email: 'user.email',
|
||||
},
|
||||
workspace: {
|
||||
id: 'workspace.id',
|
||||
name: 'workspace.name',
|
||||
organizationId: 'workspace.organizationId',
|
||||
workspaceMode: 'workspace.workspaceMode',
|
||||
},
|
||||
permissions: {
|
||||
entityId: 'permissions.entityId',
|
||||
entityType: 'permissions.entityType',
|
||||
userId: 'permissions.userId',
|
||||
},
|
||||
invitationWorkspaceGrant: {
|
||||
invitationId: 'invitationWorkspaceGrant.invitationId',
|
||||
workspaceId: 'invitationWorkspaceGrant.workspaceId',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
|
||||
eq: vi.fn((field: unknown, value: unknown) => ({ field, value })),
|
||||
inArray: vi.fn((field: unknown, values: unknown[]) => ({ field, values })),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/logger', () => loggerMock)
|
||||
|
||||
vi.mock('@sim/audit', () => auditMock)
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
getSession: mockGetSession,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/validation/seat-management', () => ({
|
||||
validateBulkInvitations: vi.fn(),
|
||||
validateSeatAvailability: mockValidateSeatAvailability,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/invitations/send', () => ({
|
||||
createPendingInvitation: mockCreatePendingInvitation,
|
||||
sendInvitationEmail: mockSendInvitationEmail,
|
||||
cancelPendingInvitation: mockCancelPendingInvitation,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/invitations/direct-grant', () => ({
|
||||
grantWorkspaceAccessDirectly: mockGrantWorkspaceAccessDirectly,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/messaging/email/validation', () => ({
|
||||
quickValidateEmail: vi.fn((email: string) => ({ isValid: email.includes('@') })),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
hasWorkspaceAdminAccess: vi.fn().mockResolvedValue(true),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/policy', () => ({
|
||||
isOrganizationWorkspace: vi.fn().mockReturnValue(true),
|
||||
}))
|
||||
|
||||
vi.mock('@/ee/access-control/utils/permission-check', () => ({
|
||||
InvitationsNotAllowedError: class InvitationsNotAllowedError extends Error {},
|
||||
validateInvitationsAllowed: mockValidateInvitationsAllowed,
|
||||
}))
|
||||
|
||||
import { POST } from '@/app/api/organizations/[id]/invitations/route'
|
||||
|
||||
describe('POST /api/organizations/[id]/invitations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDbState.selectResults = []
|
||||
mockValidateInvitationsAllowed.mockResolvedValue(undefined)
|
||||
mockValidateSeatAvailability.mockResolvedValue({
|
||||
canInvite: true,
|
||||
currentSeats: 1,
|
||||
maxSeats: 5,
|
||||
availableSeats: 4,
|
||||
})
|
||||
mockCreatePendingInvitation.mockResolvedValue({
|
||||
invitationId: 'inv-1',
|
||||
token: 'tok-1',
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
})
|
||||
mockSendInvitationEmail.mockResolvedValue({ success: true })
|
||||
mockGrantWorkspaceAccessDirectly.mockResolvedValue({ outcome: 'added', permission: 'write' })
|
||||
})
|
||||
|
||||
it('creates a unified invitation and sends a single email', async () => {
|
||||
mockGetSession.mockResolvedValue(
|
||||
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
|
||||
)
|
||||
mockDbState.selectResults = [
|
||||
[{ role: 'owner' }],
|
||||
[{ name: 'Org One' }],
|
||||
[],
|
||||
[],
|
||||
[{ name: 'Owner', email: 'owner@example.com' }],
|
||||
]
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{ emails: ['invitee@example.com'] },
|
||||
{},
|
||||
'http://localhost/api/organizations/org-1/invitations'
|
||||
),
|
||||
{ params: Promise.resolve({ id: 'org-1' }) }
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockCreatePendingInvitation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'organization',
|
||||
email: 'invitee@example.com',
|
||||
organizationId: 'org-1',
|
||||
role: 'member',
|
||||
grants: [],
|
||||
})
|
||||
)
|
||||
expect(mockSendInvitationEmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: 'organization', email: 'invitee@example.com' })
|
||||
)
|
||||
expect(mockCancelPendingInvitation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adds an existing member directly to selected workspaces they lack (no invitation/email)', async () => {
|
||||
mockGetSession.mockResolvedValue(
|
||||
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
|
||||
)
|
||||
mockDbState.selectResults = [
|
||||
[{ role: 'owner' }],
|
||||
[{ name: 'Org One' }],
|
||||
[{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }],
|
||||
[{ id: 'ws-2', name: 'Workspace 2', organizationId: 'org-1', workspaceMode: 'organization' }],
|
||||
[{ userId: 'user-2', userEmail: 'member@example.com' }],
|
||||
[],
|
||||
[{ userId: 'user-2', workspaceId: 'ws-1' }],
|
||||
[],
|
||||
[{ name: 'Owner', email: 'owner@example.com' }],
|
||||
]
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
emails: ['member@example.com'],
|
||||
workspaceInvitations: [
|
||||
{ workspaceId: 'ws-1', permission: 'write' },
|
||||
{ workspaceId: 'ws-2', permission: 'write' },
|
||||
],
|
||||
},
|
||||
{},
|
||||
'http://localhost/api/organizations/org-1/invitations?batch=true'
|
||||
),
|
||||
{ params: Promise.resolve({ id: 'org-1' }) }
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
|
||||
expect(mockSendInvitationEmail).not.toHaveBeenCalled()
|
||||
expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledTimes(1)
|
||||
expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'user-2',
|
||||
email: 'member@example.com',
|
||||
workspaceId: 'ws-2',
|
||||
permission: 'write',
|
||||
organizationId: 'org-1',
|
||||
})
|
||||
)
|
||||
|
||||
const body = await response.json()
|
||||
expect(body.data.invitationsSent).toBe(0)
|
||||
expect(body.data.directlyAdded).toEqual(['member@example.com'])
|
||||
expect(body.data.directlyAddedCount).toBe(1)
|
||||
expect(body.data.existingMembers).toEqual([])
|
||||
})
|
||||
|
||||
it('reports a partially-failed member only as added, never in both buckets', async () => {
|
||||
mockGetSession.mockResolvedValue(
|
||||
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
|
||||
)
|
||||
// First grant succeeds, second throws (e.g. transient DB error).
|
||||
mockGrantWorkspaceAccessDirectly
|
||||
.mockResolvedValueOnce({ outcome: 'added', permission: 'write' })
|
||||
.mockRejectedValueOnce(new Error('db blip'))
|
||||
mockDbState.selectResults = [
|
||||
[{ role: 'owner' }],
|
||||
[{ name: 'Org One' }],
|
||||
[{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }],
|
||||
[{ id: 'ws-2', name: 'Workspace 2', organizationId: 'org-1', workspaceMode: 'organization' }],
|
||||
[{ userId: 'user-2', userEmail: 'member@example.com' }],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[{ name: 'Owner', email: 'owner@example.com' }],
|
||||
]
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
emails: ['member@example.com'],
|
||||
workspaceInvitations: [
|
||||
{ workspaceId: 'ws-1', permission: 'write' },
|
||||
{ workspaceId: 'ws-2', permission: 'write' },
|
||||
],
|
||||
},
|
||||
{},
|
||||
'http://localhost/api/organizations/org-1/invitations?batch=true'
|
||||
),
|
||||
{ params: Promise.resolve({ id: 'org-1' }) }
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledTimes(2)
|
||||
const body = await response.json()
|
||||
expect(body.data.directlyAdded).toEqual(['member@example.com'])
|
||||
expect(body.data.failedInvitations).toEqual([])
|
||||
})
|
||||
|
||||
it('returns 207 with both successes and failures when one member is added and another fails', async () => {
|
||||
mockGetSession.mockResolvedValue(
|
||||
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
|
||||
)
|
||||
mockGrantWorkspaceAccessDirectly
|
||||
.mockResolvedValueOnce({ outcome: 'added', permission: 'write' })
|
||||
.mockRejectedValueOnce(new Error('db blip'))
|
||||
mockDbState.selectResults = [
|
||||
[{ role: 'owner' }],
|
||||
[{ name: 'Org One' }],
|
||||
[{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }],
|
||||
[
|
||||
{ userId: 'user-a', userEmail: 'a@example.com' },
|
||||
{ userId: 'user-b', userEmail: 'b@example.com' },
|
||||
],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[{ name: 'Owner', email: 'owner@example.com' }],
|
||||
]
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
emails: ['a@example.com', 'b@example.com'],
|
||||
workspaceInvitations: [{ workspaceId: 'ws-1', permission: 'write' }],
|
||||
},
|
||||
{},
|
||||
'http://localhost/api/organizations/org-1/invitations?batch=true'
|
||||
),
|
||||
{ params: Promise.resolve({ id: 'org-1' }) }
|
||||
)
|
||||
|
||||
expect(response.status).toBe(207)
|
||||
const body = await response.json()
|
||||
expect(body.success).toBe(false)
|
||||
expect(body.data.directlyAdded).toEqual(['a@example.com'])
|
||||
expect(body.data.directlyAddedCount).toBe(1)
|
||||
expect(body.data.failedInvitations).toEqual([{ email: 'b@example.com', error: 'db blip' }])
|
||||
})
|
||||
|
||||
it('returns 400 when an existing member already has access to every selected workspace', async () => {
|
||||
mockGetSession.mockResolvedValue(
|
||||
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
|
||||
)
|
||||
mockDbState.selectResults = [
|
||||
[{ role: 'owner' }],
|
||||
[{ name: 'Org One' }],
|
||||
[{ id: 'ws-1', organizationId: 'org-1', workspaceMode: 'organization' }],
|
||||
[{ userId: 'user-2', userEmail: 'member@example.com' }],
|
||||
[],
|
||||
[{ userId: 'user-2', workspaceId: 'ws-1' }],
|
||||
[],
|
||||
]
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
emails: ['member@example.com'],
|
||||
workspaceInvitations: [{ workspaceId: 'ws-1', permission: 'write' }],
|
||||
},
|
||||
{},
|
||||
'http://localhost/api/organizations/org-1/invitations?batch=true'
|
||||
),
|
||||
{ params: Promise.resolve({ id: 'org-1' }) }
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const body = await response.json()
|
||||
expect(body.error).toContain('already has access')
|
||||
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invites new emails to the organization and adds existing members to workspaces in one batch', async () => {
|
||||
mockGetSession.mockResolvedValue(
|
||||
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
|
||||
)
|
||||
mockDbState.selectResults = [
|
||||
[{ role: 'owner' }],
|
||||
[{ name: 'Org One' }],
|
||||
[{ id: 'ws-1', name: 'Workspace 1', organizationId: 'org-1', workspaceMode: 'organization' }],
|
||||
[{ userId: 'user-2', userEmail: 'member@example.com' }],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[{ name: 'Owner', email: 'owner@example.com' }],
|
||||
]
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{
|
||||
emails: ['new@example.com', 'member@example.com'],
|
||||
workspaceInvitations: [{ workspaceId: 'ws-1', permission: 'read' }],
|
||||
},
|
||||
{},
|
||||
'http://localhost/api/organizations/org-1/invitations?batch=true'
|
||||
),
|
||||
{ params: Promise.resolve({ id: 'org-1' }) }
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockCreatePendingInvitation).toHaveBeenCalledTimes(1)
|
||||
expect(mockCreatePendingInvitation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'organization',
|
||||
email: 'new@example.com',
|
||||
grants: [{ workspaceId: 'ws-1', permission: 'read' }],
|
||||
})
|
||||
)
|
||||
expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledTimes(1)
|
||||
expect(mockGrantWorkspaceAccessDirectly).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'user-2',
|
||||
email: 'member@example.com',
|
||||
workspaceId: 'ws-1',
|
||||
permission: 'read',
|
||||
})
|
||||
)
|
||||
|
||||
const body = await response.json()
|
||||
expect(body.data.invitationsSent).toBe(1)
|
||||
expect(body.data.invitedEmails).toEqual(['new@example.com'])
|
||||
expect(body.data.directlyAdded).toEqual(['member@example.com'])
|
||||
expect(body.data.directlyAddedCount).toBe(1)
|
||||
})
|
||||
|
||||
it('still rejects existing members on the non-batch organization invite path', async () => {
|
||||
mockGetSession.mockResolvedValue(
|
||||
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
|
||||
)
|
||||
mockDbState.selectResults = [
|
||||
[{ role: 'owner' }],
|
||||
[{ name: 'Org One' }],
|
||||
[{ userId: 'user-2', userEmail: 'member@example.com' }],
|
||||
[],
|
||||
]
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{ emails: ['member@example.com'] },
|
||||
{},
|
||||
'http://localhost/api/organizations/org-1/invitations'
|
||||
),
|
||||
{ params: Promise.resolve({ id: 'org-1' }) }
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const body = await response.json()
|
||||
expect(body.error).toBe(
|
||||
'Failed to send invitation. User is already a part of the organization.'
|
||||
)
|
||||
expect(mockCreatePendingInvitation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rolls back the pending invitation when email delivery fails', async () => {
|
||||
mockGetSession.mockResolvedValue(
|
||||
createSession({ userId: 'user-1', email: 'owner@example.com', name: 'Owner' })
|
||||
)
|
||||
mockDbState.selectResults = [
|
||||
[{ role: 'owner' }],
|
||||
[{ name: 'Org One' }],
|
||||
[],
|
||||
[],
|
||||
[{ name: 'Owner', email: 'owner@example.com' }],
|
||||
]
|
||||
mockSendInvitationEmail.mockResolvedValue({ success: false, error: 'mailer unavailable' })
|
||||
|
||||
const response = await POST(
|
||||
createMockRequest(
|
||||
'POST',
|
||||
{ emails: ['invitee@example.com'] },
|
||||
{},
|
||||
'http://localhost/api/organizations/org-1/invitations'
|
||||
),
|
||||
{ params: Promise.resolve({ id: 'org-1' }) }
|
||||
)
|
||||
|
||||
expect(response.status).toBe(502)
|
||||
expect(mockCancelPendingInvitation).toHaveBeenCalledWith('inv-1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,632 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
invitation,
|
||||
invitationWorkspaceGrant,
|
||||
member,
|
||||
organization,
|
||||
permissions,
|
||||
user,
|
||||
workspace,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { normalizeEmail } from '@sim/utils/string'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
inviteOrganizationMembersContract,
|
||||
organizationParamsSchema,
|
||||
} from '@/lib/api/contracts/organization'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
|
||||
import { isEnterprise } from '@/lib/billing/plan-helpers'
|
||||
import {
|
||||
validateBulkInvitations,
|
||||
validateSeatAvailability,
|
||||
} from '@/lib/billing/validation/seat-management'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { grantWorkspaceAccessDirectly } from '@/lib/invitations/direct-grant'
|
||||
import {
|
||||
cancelPendingInvitation,
|
||||
createPendingInvitation,
|
||||
sendInvitationEmail,
|
||||
} from '@/lib/invitations/send'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
|
||||
import { isOrganizationWorkspace } from '@/lib/workspaces/policy'
|
||||
import {
|
||||
InvitationsNotAllowedError,
|
||||
validateInvitationsAllowed,
|
||||
} from '@/ee/access-control/utils/permission-check'
|
||||
|
||||
const logger = createLogger('OrganizationInvitations')
|
||||
|
||||
interface WorkspaceGrantPayload {
|
||||
workspaceId: string
|
||||
permission: 'admin' | 'write' | 'read'
|
||||
}
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const paramsResult = organizationParamsSchema.safeParse(await params)
|
||||
if (!paramsResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { id: organizationId } = paramsResult.data
|
||||
|
||||
const [memberEntry] = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (!memberEntry) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const userRole = memberEntry.role
|
||||
if (!isOrgAdminRole(userRole)) {
|
||||
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const invitations = await db
|
||||
.select({
|
||||
id: invitation.id,
|
||||
email: invitation.email,
|
||||
kind: invitation.kind,
|
||||
role: invitation.role,
|
||||
status: invitation.status,
|
||||
expiresAt: invitation.expiresAt,
|
||||
createdAt: invitation.createdAt,
|
||||
inviterName: user.name,
|
||||
inviterEmail: user.email,
|
||||
})
|
||||
.from(invitation)
|
||||
.leftJoin(user, eq(invitation.inviterId, user.id))
|
||||
.where(eq(invitation.organizationId, organizationId))
|
||||
.orderBy(invitation.createdAt)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { invitations, userRole },
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get organization invitations', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(inviteOrganizationMembersContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { id: organizationId } = parsed.data.params
|
||||
|
||||
await validateInvitationsAllowed(session.user.id, { organizationId })
|
||||
|
||||
const validateOnly = parsed.data.query.validate === true
|
||||
const isBatch = parsed.data.query.batch === true
|
||||
|
||||
const { email, emails, role = 'member', workspaceInvitations } = parsed.data.body
|
||||
const invitationEmails = email ? [email] : emails
|
||||
|
||||
if (!invitationEmails || !Array.isArray(invitationEmails) || invitationEmails.length === 0) {
|
||||
return NextResponse.json({ error: 'Email or emails array is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const [memberEntry] = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
|
||||
.limit(1)
|
||||
|
||||
if (!memberEntry) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden - Not a member of this organization' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!isOrgAdminRole(memberEntry.role)) {
|
||||
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (validateOnly) {
|
||||
const validationResult = await validateBulkInvitations(organizationId, invitationEmails)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: validationResult,
|
||||
validatedBy: session.user.id,
|
||||
validatedAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
const [organizationEntry] = await db
|
||||
.select({ name: organization.name })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.limit(1)
|
||||
|
||||
if (!organizationEntry) {
|
||||
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const processedEmails = Array.from(
|
||||
new Set(
|
||||
invitationEmails
|
||||
.map((raw) => {
|
||||
const normalized = normalizeEmail(raw)
|
||||
return quickValidateEmail(normalized).isValid ? normalized : null
|
||||
})
|
||||
.filter((email): email is string => !!email)
|
||||
)
|
||||
)
|
||||
|
||||
if (processedEmails.length === 0) {
|
||||
return NextResponse.json({ error: 'No valid emails provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
const validGrants: WorkspaceGrantPayload[] = []
|
||||
const workspaceNameById = new Map<string, string>()
|
||||
if (isBatch) {
|
||||
if (!Array.isArray(workspaceInvitations) || workspaceInvitations.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Select at least one organization workspace for this invitation.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
for (const wsInvitation of workspaceInvitations) {
|
||||
if (validGrants.some((grant) => grant.workspaceId === wsInvitation.workspaceId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const canInvite = await hasWorkspaceAdminAccess(session.user.id, wsInvitation.workspaceId)
|
||||
if (!canInvite) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `You don't have permission to invite users to workspace ${wsInvitation.workspaceId}`,
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const [workspaceEntry] = await db
|
||||
.select({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
organizationId: workspace.organizationId,
|
||||
workspaceMode: workspace.workspaceMode,
|
||||
})
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, wsInvitation.workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceEntry || !isOrganizationWorkspace(workspaceEntry)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Workspace ${wsInvitation.workspaceId} is not an organization-owned workspace.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (workspaceEntry.organizationId !== organizationId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Workspace ${wsInvitation.workspaceId} does not belong to this organization.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await validateInvitationsAllowed(session.user.id, wsInvitation.workspaceId)
|
||||
|
||||
workspaceNameById.set(workspaceEntry.id, workspaceEntry.name)
|
||||
validGrants.push({
|
||||
workspaceId: wsInvitation.workspaceId,
|
||||
permission: wsInvitation.permission,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const existingMembers = await db
|
||||
.select({ userId: member.userId, userEmail: user.email })
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
const memberUserIdByEmail = new Map(
|
||||
existingMembers.map((m) => [m.userEmail.toLowerCase(), m.userId])
|
||||
)
|
||||
const newEmails = processedEmails.filter((email) => !memberUserIdByEmail.has(email))
|
||||
const memberEmails = processedEmails.filter((email) => memberUserIdByEmail.has(email))
|
||||
|
||||
const existingInvitations = await db
|
||||
.select({ email: invitation.email })
|
||||
.from(invitation)
|
||||
.where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending')))
|
||||
const pendingEmails = existingInvitations.map((i) => i.email.toLowerCase())
|
||||
const emailsToInvite = newEmails.filter((email) => !pendingEmails.includes(email))
|
||||
|
||||
/**
|
||||
* Existing organization members are not re-invited to the organization,
|
||||
* but in batch mode they still receive a workspace invitation covering
|
||||
* the selected workspaces they don't already have access to (or a
|
||||
* pending invitation for). The inviter's own email is always treated as
|
||||
* covered.
|
||||
*/
|
||||
const memberWorkspaceInvites: Array<{ email: string; grants: WorkspaceGrantPayload[] }> = []
|
||||
const membersAlreadyCovered: string[] = []
|
||||
|
||||
if (isBatch) {
|
||||
const inviterEmail = session.user.email?.toLowerCase() ?? null
|
||||
const eligibleMemberEmails = memberEmails.filter((email) => email !== inviterEmail)
|
||||
membersAlreadyCovered.push(...memberEmails.filter((email) => email === inviterEmail))
|
||||
|
||||
const grantWorkspaceIds = validGrants.map((grant) => grant.workspaceId)
|
||||
const eligibleMemberUserIds = eligibleMemberEmails.map(
|
||||
(email) => memberUserIdByEmail.get(email) as string
|
||||
)
|
||||
|
||||
const accessibleRows =
|
||||
eligibleMemberUserIds.length > 0
|
||||
? await db
|
||||
.select({ userId: permissions.userId, workspaceId: permissions.entityId })
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
inArray(permissions.userId, eligibleMemberUserIds),
|
||||
inArray(permissions.entityId, grantWorkspaceIds)
|
||||
)
|
||||
)
|
||||
: []
|
||||
const accessibleByUserId = new Map<string, Set<string>>()
|
||||
for (const row of accessibleRows) {
|
||||
const workspaceIds = accessibleByUserId.get(row.userId) ?? new Set<string>()
|
||||
workspaceIds.add(row.workspaceId)
|
||||
accessibleByUserId.set(row.userId, workspaceIds)
|
||||
}
|
||||
|
||||
const pendingGrantRows =
|
||||
eligibleMemberEmails.length > 0
|
||||
? await db
|
||||
.select({
|
||||
email: invitation.email,
|
||||
workspaceId: invitationWorkspaceGrant.workspaceId,
|
||||
})
|
||||
.from(invitationWorkspaceGrant)
|
||||
.innerJoin(invitation, eq(invitation.id, invitationWorkspaceGrant.invitationId))
|
||||
.where(
|
||||
and(
|
||||
inArray(invitationWorkspaceGrant.workspaceId, grantWorkspaceIds),
|
||||
inArray(invitation.email, eligibleMemberEmails),
|
||||
eq(invitation.status, 'pending')
|
||||
)
|
||||
)
|
||||
: []
|
||||
const pendingWorkspaceIdsByEmail = new Map<string, Set<string>>()
|
||||
for (const row of pendingGrantRows) {
|
||||
const email = row.email.toLowerCase()
|
||||
const workspaceIds = pendingWorkspaceIdsByEmail.get(email) ?? new Set<string>()
|
||||
workspaceIds.add(row.workspaceId)
|
||||
pendingWorkspaceIdsByEmail.set(email, workspaceIds)
|
||||
}
|
||||
|
||||
for (const email of eligibleMemberEmails) {
|
||||
const memberUserId = memberUserIdByEmail.get(email) as string
|
||||
const accessibleWorkspaceIds = accessibleByUserId.get(memberUserId)
|
||||
const pendingWorkspaceIds = pendingWorkspaceIdsByEmail.get(email)
|
||||
|
||||
const grantsNeeded = validGrants.filter(
|
||||
(grant) =>
|
||||
!accessibleWorkspaceIds?.has(grant.workspaceId) &&
|
||||
!pendingWorkspaceIds?.has(grant.workspaceId)
|
||||
)
|
||||
|
||||
if (grantsNeeded.length > 0) {
|
||||
memberWorkspaceInvites.push({ email, grants: grantsNeeded })
|
||||
} else {
|
||||
membersAlreadyCovered.push(email)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
membersAlreadyCovered.push(...memberEmails)
|
||||
}
|
||||
|
||||
if (emailsToInvite.length === 0 && memberWorkspaceInvites.length === 0) {
|
||||
const isSingleEmail = processedEmails.length === 1
|
||||
const pendingInvitationEmails = processedEmails.filter((email) =>
|
||||
pendingEmails.includes(email)
|
||||
)
|
||||
|
||||
if (isSingleEmail) {
|
||||
if (membersAlreadyCovered.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: isBatch
|
||||
? 'Failed to send invitation. User already has access or a pending invitation to every selected workspace.'
|
||||
: 'Failed to send invitation. User is already a part of the organization.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
if (pendingInvitationEmails.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Failed to send invitation. A pending invitation already exists for this email.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: isBatch
|
||||
? 'All emails are already members with access to the selected workspaces or have pending invitations.'
|
||||
: 'All emails are already members or have pending invitations.',
|
||||
details: {
|
||||
existingMembers: membersAlreadyCovered,
|
||||
pendingInvitations: pendingInvitationEmails,
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const orgSubscription = await getOrganizationSubscription(organizationId)
|
||||
const enforceFixedSeats = !!orgSubscription && isEnterprise(orgSubscription.plan)
|
||||
const seatValidation =
|
||||
enforceFixedSeats && emailsToInvite.length > 0
|
||||
? await validateSeatAvailability(organizationId, emailsToInvite.length)
|
||||
: null
|
||||
if (seatValidation && !seatValidation.canInvite) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: seatValidation.reason,
|
||||
seatInfo: {
|
||||
currentSeats: seatValidation.currentSeats,
|
||||
maxSeats: seatValidation.maxSeats,
|
||||
availableSeats: seatValidation.availableSeats,
|
||||
seatsRequested: emailsToInvite.length,
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const [inviterRow] = await db
|
||||
.select({ name: user.name, email: user.email })
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.limit(1)
|
||||
const inviterName = inviterRow?.name || inviterRow?.email || 'A user'
|
||||
|
||||
const failedInvitations: Array<{ email: string; error: string }> = []
|
||||
|
||||
/**
|
||||
* Brand-new emails receive an organization invitation (with all selected
|
||||
* workspace grants) that still requires acceptance — accepting is what
|
||||
* joins them to the org and consumes a seat.
|
||||
*/
|
||||
const sentInvitations: Array<{ id: string; email: string; workspaceIds: string[] }> = []
|
||||
|
||||
for (const email of emailsToInvite) {
|
||||
try {
|
||||
const { invitationId, token } = await createPendingInvitation({
|
||||
kind: 'organization',
|
||||
email,
|
||||
inviterId: session.user.id,
|
||||
organizationId,
|
||||
membershipIntent: 'internal',
|
||||
role,
|
||||
grants: validGrants,
|
||||
})
|
||||
|
||||
const emailResult = await sendInvitationEmail({
|
||||
invitationId,
|
||||
token,
|
||||
kind: 'organization',
|
||||
email,
|
||||
inviterName,
|
||||
organizationId,
|
||||
organizationRole: role,
|
||||
grants: validGrants,
|
||||
})
|
||||
|
||||
if (!emailResult.success) {
|
||||
logger.error('Failed to send invitation email', {
|
||||
email,
|
||||
error: emailResult.error,
|
||||
})
|
||||
failedInvitations.push({
|
||||
email,
|
||||
error: emailResult.error || 'Unknown email delivery error',
|
||||
})
|
||||
await cancelPendingInvitation(invitationId)
|
||||
continue
|
||||
}
|
||||
|
||||
sentInvitations.push({
|
||||
id: invitationId,
|
||||
email,
|
||||
workspaceIds: validGrants.map((grant) => grant.workspaceId),
|
||||
})
|
||||
} catch (creationError) {
|
||||
logger.error('Failed to create invitation', {
|
||||
email,
|
||||
error: creationError,
|
||||
})
|
||||
failedInvitations.push({
|
||||
email,
|
||||
error: getErrorMessage(creationError, 'Failed to create invitation'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Existing organization members are granted workspace access directly —
|
||||
* no invitation, no acceptance step. They are already in the org, so no
|
||||
* seat is consumed. The grant is idempotent and upgrades lower access.
|
||||
*/
|
||||
const directlyAdded: string[] = []
|
||||
|
||||
for (const memberInvite of memberWorkspaceInvites) {
|
||||
const memberUserId = memberUserIdByEmail.get(memberInvite.email)
|
||||
if (!memberUserId) continue
|
||||
|
||||
let addedAny = false
|
||||
let lastGrantError: string | null = null
|
||||
for (const grant of memberInvite.grants) {
|
||||
try {
|
||||
const grantResult = await grantWorkspaceAccessDirectly({
|
||||
userId: memberUserId,
|
||||
email: memberInvite.email,
|
||||
workspaceId: grant.workspaceId,
|
||||
workspaceName: workspaceNameById.get(grant.workspaceId) ?? 'a workspace',
|
||||
permission: grant.permission,
|
||||
organizationId,
|
||||
actorId: session.user.id,
|
||||
actorName: inviterName,
|
||||
actorEmail: session.user.email,
|
||||
request,
|
||||
})
|
||||
|
||||
if (grantResult.outcome === 'added') addedAny = true
|
||||
} catch (grantError) {
|
||||
logger.error('Failed to grant workspace access directly', {
|
||||
email: memberInvite.email,
|
||||
workspaceId: grant.workspaceId,
|
||||
error: grantError,
|
||||
})
|
||||
lastGrantError = getErrorMessage(grantError, 'Failed to add member to workspace')
|
||||
}
|
||||
}
|
||||
|
||||
if (addedAny) {
|
||||
directlyAdded.push(memberInvite.email)
|
||||
} else if (lastGrantError) {
|
||||
failedInvitations.push({ email: memberInvite.email, error: lastGrantError })
|
||||
}
|
||||
}
|
||||
|
||||
for (const inv of sentInvitations) {
|
||||
recordAudit({
|
||||
workspaceId: null,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.ORG_INVITATION_CREATED,
|
||||
resourceType: AuditResourceType.ORGANIZATION,
|
||||
resourceId: organizationId,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
resourceName: organizationEntry.name,
|
||||
description: `Invited ${inv.email} to organization as ${role}`,
|
||||
metadata: {
|
||||
invitationId: inv.id,
|
||||
targetEmail: inv.email,
|
||||
targetRole: role,
|
||||
isBatch,
|
||||
workspaceGrantCount: validGrants.length,
|
||||
enforcedFixedSeats: enforceFixedSeats,
|
||||
plan: orgSubscription?.plan ?? null,
|
||||
},
|
||||
request,
|
||||
})
|
||||
}
|
||||
|
||||
const totalInvitationsSent = sentInvitations.length
|
||||
const totalSucceeded = totalInvitationsSent + directlyAdded.length
|
||||
const responseData = {
|
||||
invitationsSent: totalInvitationsSent,
|
||||
invitedEmails: sentInvitations.map((inv) => inv.email),
|
||||
directlyAdded,
|
||||
directlyAddedCount: directlyAdded.length,
|
||||
failedInvitations,
|
||||
existingMembers: membersAlreadyCovered,
|
||||
pendingInvitations: processedEmails.filter(
|
||||
(email) => pendingEmails.includes(email) && !memberUserIdByEmail.has(email)
|
||||
),
|
||||
invalidEmails: invitationEmails.filter(
|
||||
(email) => !quickValidateEmail(normalizeEmail(email)).isValid
|
||||
),
|
||||
workspaceGrantsPerInvite: validGrants.length,
|
||||
...(seatValidation
|
||||
? {
|
||||
seatInfo: {
|
||||
seatsUsed: seatValidation.currentSeats + totalInvitationsSent,
|
||||
maxSeats: seatValidation.maxSeats,
|
||||
availableSeats: seatValidation.availableSeats - totalInvitationsSent,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
|
||||
const summaryParts: string[] = []
|
||||
if (totalInvitationsSent > 0) summaryParts.push(`${totalInvitationsSent} invitation(s) sent`)
|
||||
if (directlyAdded.length > 0) summaryParts.push(`${directlyAdded.length} member(s) added`)
|
||||
const summary = summaryParts.join(', ')
|
||||
|
||||
if (failedInvitations.length > 0 && totalSucceeded === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to send invitations.',
|
||||
message: 'No invitations could be delivered.',
|
||||
data: responseData,
|
||||
},
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
if (failedInvitations.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Some invitations failed.',
|
||||
message: `${summary}, ${failedInvitations.length} failed`,
|
||||
data: responseData,
|
||||
},
|
||||
{ status: 207 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `${summary || 'No changes'} successfully`,
|
||||
data: responseData,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof InvitationsNotAllowedError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 403 })
|
||||
}
|
||||
logger.error('Failed to create organization invitations', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user