mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(organization): invite validation experience (#5008)
* improvement(organization): invite validation experience * address comments
This commit is contained in:
@@ -41,7 +41,7 @@ const emailTemplates = {
|
||||
'workspace-invitation': () =>
|
||||
renderWorkspaceInvitationEmail(
|
||||
'John Smith',
|
||||
'Engineering Team',
|
||||
['Engineering Team'],
|
||||
'https://sim.ai/workspace/invite/abc123'
|
||||
),
|
||||
|
||||
|
||||
@@ -79,6 +79,15 @@ vi.mock('@sim/db/schema', () => ({
|
||||
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', () => ({
|
||||
@@ -182,6 +191,175 @@ describe('POST /api/organizations/[id]/invitations', () => {
|
||||
expect(mockCancelPendingInvitation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends a workspace invitation to an existing member for selected workspaces they lack', 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' }],
|
||||
[{ id: 'ws-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).toHaveBeenCalledTimes(1)
|
||||
expect(mockCreatePendingInvitation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'workspace',
|
||||
email: 'member@example.com',
|
||||
organizationId: 'org-1',
|
||||
membershipIntent: 'internal',
|
||||
grants: [{ workspaceId: 'ws-2', permission: 'write' }],
|
||||
})
|
||||
)
|
||||
expect(mockSendInvitationEmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'workspace',
|
||||
email: 'member@example.com',
|
||||
grants: [{ workspaceId: 'ws-2', permission: 'write' }],
|
||||
})
|
||||
)
|
||||
|
||||
const body = await response.json()
|
||||
expect(body.data.invitationsSent).toBe(1)
|
||||
expect(body.data.invitedEmails).toEqual(['member@example.com'])
|
||||
expect(body.data.existingMembers).toEqual([])
|
||||
})
|
||||
|
||||
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 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', 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(2)
|
||||
expect(mockCreatePendingInvitation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'organization',
|
||||
email: 'new@example.com',
|
||||
grants: [{ workspaceId: 'ws-1', permission: 'read' }],
|
||||
})
|
||||
)
|
||||
expect(mockCreatePendingInvitation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'workspace',
|
||||
email: 'member@example.com',
|
||||
grants: [{ workspaceId: 'ws-1', permission: 'read' }],
|
||||
})
|
||||
)
|
||||
|
||||
const body = await response.json()
|
||||
expect(body.data.invitationsSent).toBe(2)
|
||||
expect(body.data.invitedEmails).toEqual(['new@example.com', 'member@example.com'])
|
||||
})
|
||||
|
||||
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' })
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { invitation, member, organization, user, workspace } from '@sim/db/schema'
|
||||
import {
|
||||
invitation,
|
||||
invitationWorkspaceGrant,
|
||||
member,
|
||||
organization,
|
||||
permissions,
|
||||
user,
|
||||
workspace,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
inviteOrganizationMembersContract,
|
||||
@@ -188,6 +197,10 @@ export const POST = withRouteHandler(
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -236,12 +249,15 @@ export const POST = withRouteHandler(
|
||||
}
|
||||
|
||||
const existingMembers = await db
|
||||
.select({ userEmail: user.email })
|
||||
.select({ userId: member.userId, userEmail: user.email })
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
const existingEmails = existingMembers.map((m) => m.userEmail.toLowerCase())
|
||||
const newEmails = processedEmails.filter((email) => !existingEmails.includes(email))
|
||||
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 })
|
||||
@@ -250,19 +266,106 @@ export const POST = withRouteHandler(
|
||||
const pendingEmails = existingInvitations.map((i) => i.email.toLowerCase())
|
||||
const emailsToInvite = newEmails.filter((email) => !pendingEmails.includes(email))
|
||||
|
||||
if (emailsToInvite.length === 0) {
|
||||
const isSingleEmail = processedEmails.length === 1
|
||||
const existingMembersEmails = processedEmails.filter((email) =>
|
||||
existingEmails.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 (existingMembersEmails.length > 0) {
|
||||
if (membersAlreadyCovered.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to send invitation. User is already a part of the organization.' },
|
||||
{
|
||||
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 }
|
||||
)
|
||||
}
|
||||
@@ -279,9 +382,11 @@ export const POST = withRouteHandler(
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'All emails are already members or have pending invitations.',
|
||||
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: existingMembersEmails,
|
||||
existingMembers: membersAlreadyCovered,
|
||||
pendingInvitations: pendingInvitationEmails,
|
||||
},
|
||||
},
|
||||
@@ -291,9 +396,10 @@ export const POST = withRouteHandler(
|
||||
|
||||
const orgSubscription = await getOrganizationSubscription(organizationId)
|
||||
const enforceFixedSeats = !!orgSubscription && isEnterprise(orgSubscription.plan)
|
||||
const seatValidation = enforceFixedSeats
|
||||
? await validateSeatAvailability(organizationId, emailsToInvite.length)
|
||||
: null
|
||||
const seatValidation =
|
||||
enforceFixedSeats && emailsToInvite.length > 0
|
||||
? await validateSeatAvailability(organizationId, emailsToInvite.length)
|
||||
: null
|
||||
if (seatValidation && !seatValidation.canInvite) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -316,88 +422,148 @@ export const POST = withRouteHandler(
|
||||
.limit(1)
|
||||
const inviterName = inviterRow?.name || inviterRow?.email || 'A user'
|
||||
|
||||
const sentInvitations: Array<{ id: string; email: string }> = []
|
||||
/**
|
||||
* Organization invitations (new emails, all selected grants) and
|
||||
* workspace invitations (existing members, only the grants they lack)
|
||||
* share one create/send/rollback pipeline; they differ only in `kind`,
|
||||
* grants, and audit treatment.
|
||||
*/
|
||||
const pendingSends = [
|
||||
...emailsToInvite.map((email) => ({
|
||||
kind: 'organization' as const,
|
||||
email,
|
||||
grants: validGrants,
|
||||
})),
|
||||
...memberWorkspaceInvites.map((memberInvite) => ({
|
||||
kind: 'workspace' as const,
|
||||
email: memberInvite.email,
|
||||
grants: memberInvite.grants,
|
||||
})),
|
||||
]
|
||||
|
||||
const sentInvitations: Array<{
|
||||
id: string
|
||||
email: string
|
||||
kind: 'organization' | 'workspace'
|
||||
workspaceIds: string[]
|
||||
}> = []
|
||||
const failedInvitations: Array<{ email: string; error: string }> = []
|
||||
|
||||
for (const email of emailsToInvite) {
|
||||
for (const send of pendingSends) {
|
||||
const sendRole = send.kind === 'organization' ? role : 'member'
|
||||
try {
|
||||
const { invitationId, token } = await createPendingInvitation({
|
||||
kind: 'organization',
|
||||
email,
|
||||
kind: send.kind,
|
||||
email: send.email,
|
||||
inviterId: session.user.id,
|
||||
organizationId,
|
||||
role,
|
||||
grants: validGrants,
|
||||
membershipIntent: 'internal',
|
||||
role: sendRole,
|
||||
grants: send.grants,
|
||||
})
|
||||
|
||||
const emailResult = await sendInvitationEmail({
|
||||
invitationId,
|
||||
token,
|
||||
kind: 'organization',
|
||||
email,
|
||||
kind: send.kind,
|
||||
email: send.email,
|
||||
inviterName,
|
||||
organizationId,
|
||||
organizationRole: role,
|
||||
grants: validGrants,
|
||||
organizationRole: sendRole,
|
||||
grants: send.grants,
|
||||
})
|
||||
|
||||
if (!emailResult.success) {
|
||||
logger.error('Failed to send organization invitation email', {
|
||||
email,
|
||||
logger.error('Failed to send invitation email', {
|
||||
kind: send.kind,
|
||||
email: send.email,
|
||||
error: emailResult.error,
|
||||
})
|
||||
failedInvitations.push({
|
||||
email,
|
||||
email: send.email,
|
||||
error: emailResult.error || 'Unknown email delivery error',
|
||||
})
|
||||
await cancelPendingInvitation(invitationId)
|
||||
continue
|
||||
}
|
||||
|
||||
sentInvitations.push({ id: invitationId, email })
|
||||
sentInvitations.push({
|
||||
id: invitationId,
|
||||
email: send.email,
|
||||
kind: send.kind,
|
||||
workspaceIds: send.grants.map((grant) => grant.workspaceId),
|
||||
})
|
||||
} catch (creationError) {
|
||||
logger.error('Failed to create organization invitation', { email, error: creationError })
|
||||
logger.error('Failed to create invitation', {
|
||||
kind: send.kind,
|
||||
email: send.email,
|
||||
error: creationError,
|
||||
})
|
||||
failedInvitations.push({
|
||||
email,
|
||||
error:
|
||||
creationError instanceof Error
|
||||
? creationError.message
|
||||
: 'Failed to create invitation',
|
||||
email: send.email,
|
||||
error: getErrorMessage(creationError, 'Failed to create invitation'),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
if (inv.kind === 'organization') {
|
||||
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,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
for (const workspaceId of inv.workspaceIds) {
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
action: AuditAction.MEMBER_INVITED,
|
||||
resourceType: AuditResourceType.WORKSPACE,
|
||||
resourceId: workspaceId,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
resourceName: inv.email,
|
||||
description: `Invited existing organization member ${inv.email} to workspace`,
|
||||
metadata: {
|
||||
invitationId: inv.id,
|
||||
targetEmail: inv.email,
|
||||
organizationId,
|
||||
isBatch,
|
||||
},
|
||||
request,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const sentEmails = sentInvitations.map((inv) => inv.email)
|
||||
const sentOrgInvitations = sentInvitations.filter((inv) => inv.kind === 'organization')
|
||||
const totalInvitationsSent = sentInvitations.length
|
||||
const responseData = {
|
||||
invitationsSent: sentInvitations.length,
|
||||
invitedEmails: sentEmails,
|
||||
invitationsSent: totalInvitationsSent,
|
||||
invitedEmails: sentInvitations.map((inv) => inv.email),
|
||||
failedInvitations,
|
||||
existingMembers: processedEmails.filter((email) => existingEmails.includes(email)),
|
||||
pendingInvitations: processedEmails.filter((email) => pendingEmails.includes(email)),
|
||||
existingMembers: membersAlreadyCovered,
|
||||
pendingInvitations: processedEmails.filter(
|
||||
(email) => pendingEmails.includes(email) && !memberUserIdByEmail.has(email)
|
||||
),
|
||||
invalidEmails: invitationEmails.filter(
|
||||
(email) => !quickValidateEmail(email.trim().toLowerCase()).isValid
|
||||
),
|
||||
@@ -405,15 +571,15 @@ export const POST = withRouteHandler(
|
||||
...(seatValidation
|
||||
? {
|
||||
seatInfo: {
|
||||
seatsUsed: seatValidation.currentSeats + sentInvitations.length,
|
||||
seatsUsed: seatValidation.currentSeats + sentOrgInvitations.length,
|
||||
maxSeats: seatValidation.maxSeats,
|
||||
availableSeats: seatValidation.availableSeats - sentInvitations.length,
|
||||
availableSeats: seatValidation.availableSeats - sentOrgInvitations.length,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
|
||||
if (failedInvitations.length > 0 && sentInvitations.length === 0) {
|
||||
if (failedInvitations.length > 0 && totalInvitationsSent === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
@@ -430,7 +596,7 @@ export const POST = withRouteHandler(
|
||||
{
|
||||
success: false,
|
||||
error: 'Some invitation emails failed to send.',
|
||||
message: `${sentInvitations.length} invitation(s) sent, ${failedInvitations.length} failed`,
|
||||
message: `${totalInvitationsSent} invitation(s) sent, ${failedInvitations.length} failed`,
|
||||
data: responseData,
|
||||
},
|
||||
{ status: 207 }
|
||||
@@ -439,7 +605,7 @@ export const POST = withRouteHandler(
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `${sentInvitations.length} invitation(s) sent successfully`,
|
||||
message: `${totalInvitationsSent} invitation(s) sent successfully`,
|
||||
data: responseData,
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
+28
-10
@@ -29,21 +29,31 @@ interface OrganizationInviteModalProps {
|
||||
organizationId: string
|
||||
/** Workspaces the inviter can grant access to. */
|
||||
workspaces: Array<{ id: string; name: string }>
|
||||
/** Emails that already belong to the organization (rejected as duplicates). */
|
||||
existingEmails?: string[]
|
||||
/** Emails of external collaborators (rejected — they cannot join the organization). */
|
||||
externalEmails?: string[]
|
||||
/**
|
||||
* Non-member emails with a pending invitation (rejected as duplicates).
|
||||
* Member emails are always allowed — they receive workspace invitations for
|
||||
* the selected workspaces they aren't in yet, deduped per workspace by the
|
||||
* server — so the parent excludes them from this list.
|
||||
*/
|
||||
pendingEmails?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Organization-level invite modal: enter emails, pick one or more workspaces to
|
||||
* grant access to, choose a role applied to every selected workspace, and send
|
||||
* through the organization invite path.
|
||||
* through the organization invite path. Emails of existing organization
|
||||
* members are accepted — the server sends them workspace-only invitations for
|
||||
* the selected workspaces they don't already have access to.
|
||||
*/
|
||||
export function OrganizationInviteModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
organizationId,
|
||||
workspaces,
|
||||
existingEmails = [],
|
||||
externalEmails = [],
|
||||
pendingEmails = [],
|
||||
}: OrganizationInviteModalProps) {
|
||||
const [emails, setEmails] = useState<string[]>([])
|
||||
const [selectedWorkspaceIds, setSelectedWorkspaceIds] = useState<string[]>([])
|
||||
@@ -59,9 +69,14 @@ export function OrganizationInviteModal({
|
||||
[workspaces]
|
||||
)
|
||||
|
||||
const existingEmailSet = useMemo(
|
||||
() => new Set(existingEmails.map((email) => email.toLowerCase())),
|
||||
[existingEmails]
|
||||
const externalEmailSet = useMemo(
|
||||
() => new Set(externalEmails.map((email) => email.toLowerCase())),
|
||||
[externalEmails]
|
||||
)
|
||||
|
||||
const pendingEmailSet = useMemo(
|
||||
() => new Set(pendingEmails.map((email) => email.toLowerCase())),
|
||||
[pendingEmails]
|
||||
)
|
||||
|
||||
const validateEmail = useCallback(
|
||||
@@ -69,12 +84,15 @@ export function OrganizationInviteModal({
|
||||
if (session?.user?.email && session.user.email.toLowerCase() === email) {
|
||||
return 'You cannot invite yourself'
|
||||
}
|
||||
if (existingEmailSet.has(email)) {
|
||||
return `${email} is already in this organization`
|
||||
if (externalEmailSet.has(email)) {
|
||||
return `${email} belongs to another organization and can't be invited. Invite them to individual workspaces from the Teammates tab.`
|
||||
}
|
||||
if (pendingEmailSet.has(email)) {
|
||||
return `${email} already has a pending invitation`
|
||||
}
|
||||
return null
|
||||
},
|
||||
[session?.user?.email, existingEmailSet]
|
||||
[session?.user?.email, externalEmailSet, pendingEmailSet]
|
||||
)
|
||||
|
||||
const handleEmailsChange = useCallback((next: string[]) => {
|
||||
|
||||
+25
-5
@@ -98,10 +98,29 @@ export function TeamManagement() {
|
||||
}
|
||||
: null
|
||||
|
||||
const existingEmails = useMemo(() => {
|
||||
const memberEmails = (roster?.members ?? []).map((member) => member.email)
|
||||
const pendingEmails = (roster?.pendingInvitations ?? []).map((invitation) => invitation.email)
|
||||
return [...memberEmails, ...pendingEmails]
|
||||
const externalEmails = useMemo(
|
||||
() =>
|
||||
(roster?.members ?? [])
|
||||
.filter((member) => member.role === 'external')
|
||||
.map((member) => member.email),
|
||||
[roster]
|
||||
)
|
||||
|
||||
/**
|
||||
* Pending invitations for emails that already belong to a member are
|
||||
* excluded: members can always be re-invited to additional workspaces (the
|
||||
* server dedupes per workspace), so only non-member pending emails are
|
||||
* blocked in the invite modal.
|
||||
*/
|
||||
const pendingEmails = useMemo(() => {
|
||||
const memberEmailSet = new Set(
|
||||
(roster?.members ?? [])
|
||||
.filter((member) => member.role !== 'external')
|
||||
.map((member) => member.email.toLowerCase())
|
||||
)
|
||||
return (roster?.pendingInvitations ?? [])
|
||||
.map((invitation) => invitation.email)
|
||||
.filter((email) => !memberEmailSet.has(email.toLowerCase()))
|
||||
}, [roster])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -344,7 +363,8 @@ export function TeamManagement() {
|
||||
onOpenChange={setInviteModalOpen}
|
||||
organizationId={displayOrganization.id}
|
||||
workspaces={roster?.workspaces ?? []}
|
||||
existingEmails={existingEmails}
|
||||
externalEmails={externalEmails}
|
||||
pendingEmails={pendingEmails}
|
||||
/>
|
||||
|
||||
<TransferOwnershipDialog
|
||||
|
||||
+21
-8
@@ -620,6 +620,13 @@ function AuthSelector({
|
||||
const [copySuccess, setCopySuccess] = useState(false)
|
||||
const [invalidEmailItems, setInvalidEmailItems] = useState<TagItem[]>([])
|
||||
|
||||
const emailsRef = useRef(emails)
|
||||
const invalidEmailItemsRef = useRef(invalidEmailItems)
|
||||
|
||||
useEffect(() => {
|
||||
emailsRef.current = emails
|
||||
}, [emails])
|
||||
|
||||
useEffect(() => {
|
||||
if (!copySuccess) return
|
||||
const timer = setTimeout(() => setCopySuccess(false), 2000)
|
||||
@@ -645,20 +652,22 @@ function AuthSelector({
|
||||
const isValid = validation.isValid || isDomainPattern
|
||||
|
||||
if (
|
||||
emails.includes(normalized) ||
|
||||
invalidEmailItems.some((item) => item.value === normalized)
|
||||
emailsRef.current.includes(normalized) ||
|
||||
invalidEmailItemsRef.current.some((item) => item.value === normalized)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isValid) {
|
||||
setEmailError('')
|
||||
onEmailsChange([...emails, normalized])
|
||||
emailsRef.current = [...emailsRef.current, normalized]
|
||||
onEmailsChange(emailsRef.current)
|
||||
} else {
|
||||
setInvalidEmailItems((prev) => [
|
||||
...prev,
|
||||
invalidEmailItemsRef.current = [
|
||||
...invalidEmailItemsRef.current,
|
||||
{ value: normalized, isValid, error: validation.reason ?? 'Invalid email format' },
|
||||
])
|
||||
]
|
||||
setInvalidEmailItems(invalidEmailItemsRef.current)
|
||||
}
|
||||
|
||||
return isValid
|
||||
@@ -674,9 +683,13 @@ function AuthSelector({
|
||||
if (!itemToRemove) return
|
||||
|
||||
if (itemToRemove.isValid) {
|
||||
onEmailsChange(emails.filter((e) => e !== itemToRemove.value))
|
||||
emailsRef.current = emailsRef.current.filter((e) => e !== itemToRemove.value)
|
||||
onEmailsChange(emailsRef.current)
|
||||
} else {
|
||||
setInvalidEmailItems((prev) => prev.filter((item) => item.value !== itemToRemove.value))
|
||||
invalidEmailItemsRef.current = invalidEmailItemsRef.current.filter(
|
||||
(item) => item.value !== itemToRemove.value
|
||||
)
|
||||
setInvalidEmailItems(invalidEmailItemsRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,27 +4,40 @@ import { EmailLayout } from '@/components/emails/components'
|
||||
import { getBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface WorkspaceInvitationEmailProps {
|
||||
workspaceName?: string
|
||||
/** Workspaces this invitation grants access to (one entry per workspace). */
|
||||
workspaceNames?: string[]
|
||||
inviterName?: string
|
||||
invitationLink?: string
|
||||
}
|
||||
|
||||
export function WorkspaceInvitationEmail({
|
||||
workspaceName = 'Workspace',
|
||||
workspaceNames = ['Workspace'],
|
||||
inviterName = 'Someone',
|
||||
invitationLink = '',
|
||||
}: WorkspaceInvitationEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
const isMultiple = workspaceNames.length > 1
|
||||
const preview = isMultiple
|
||||
? `You've been invited to join ${workspaceNames.length} workspaces on ${brand.name}!`
|
||||
: `You've been invited to join the "${workspaceNames[0]}" workspace on ${brand.name}!`
|
||||
|
||||
return (
|
||||
<EmailLayout
|
||||
preview={`You've been invited to join the "${workspaceName}" workspace on ${brand.name}!`}
|
||||
showUnsubscribe={false}
|
||||
>
|
||||
<EmailLayout preview={preview} showUnsubscribe={false}>
|
||||
<Text style={baseStyles.paragraph}>Hello,</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
<strong>{inviterName}</strong> invited you to join the <strong>{workspaceName}</strong>{' '}
|
||||
workspace on {brand.name}.
|
||||
<strong>{inviterName}</strong> invited you to join the{' '}
|
||||
{workspaceNames.map((name, index) => (
|
||||
<span key={`${name}-${index}`}>
|
||||
{index > 0 &&
|
||||
(index === workspaceNames.length - 1
|
||||
? workspaceNames.length > 2
|
||||
? ', and '
|
||||
: ' and '
|
||||
: ', ')}
|
||||
<strong>{name}</strong>
|
||||
</span>
|
||||
))}{' '}
|
||||
{isMultiple ? 'workspaces' : 'workspace'} on {brand.name}.
|
||||
</Text>
|
||||
|
||||
<Link href={invitationLink} style={{ textDecoration: 'none' }}>
|
||||
|
||||
@@ -203,13 +203,13 @@ export async function renderCreditPurchaseEmail(params: {
|
||||
|
||||
export async function renderWorkspaceInvitationEmail(
|
||||
inviterName: string,
|
||||
workspaceName: string,
|
||||
workspaceNames: string[],
|
||||
invitationLink: string
|
||||
): Promise<string> {
|
||||
return await render(
|
||||
WorkspaceInvitationEmail({
|
||||
inviterName,
|
||||
workspaceName,
|
||||
workspaceNames,
|
||||
invitationLink,
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { type ComponentType, forwardRef, type ReactNode, useMemo, useState } from 'react'
|
||||
import {
|
||||
type ComponentType,
|
||||
forwardRef,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import { chipVariants, TRIGGER_BORDER_CLASS } from '@/components/emcn/components/chip/chip'
|
||||
import {
|
||||
@@ -10,6 +17,7 @@ import {
|
||||
DropdownMenuSearchInput,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/emcn/components/dropdown-menu/dropdown-menu'
|
||||
import { InsideModalContext } from '@/components/emcn/components/modal/modal'
|
||||
import { Check, ChevronDown } from '@/components/emcn/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
@@ -168,6 +176,15 @@ const ChipDropdown = forwardRef<HTMLButtonElement, ChipDropdownProps>(
|
||||
[isMultiple, props.value]
|
||||
)
|
||||
|
||||
/**
|
||||
* Inside a modal dialog the menu must be modal too: a non-modal menu
|
||||
* portaled to `body` inherits the dialog's `pointer-events: none` body
|
||||
* lock and outside-scroll lock (unclickable, unscrollable), and the
|
||||
* dialog's still-active focus trap fights item focus. Outside dialogs we
|
||||
* stay non-modal so filter chips don't lock page scroll while open.
|
||||
*/
|
||||
const insideModal = useContext(InsideModalContext)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const searchable = isMultiple && props.searchable === true
|
||||
@@ -254,7 +271,7 @@ const ChipDropdown = forwardRef<HTMLButtonElement, ChipDropdownProps>(
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
modal={insideModal}
|
||||
{...(isMultiple
|
||||
? {
|
||||
open,
|
||||
|
||||
@@ -582,6 +582,21 @@ function ChipModalEmailsControl({
|
||||
}: ChipModalEmailsFieldProps & { id: string; errorId: string }) {
|
||||
const [items, setItems] = React.useState<TagItem[]>([])
|
||||
|
||||
/**
|
||||
* Synchronous mirror of `items`. Pasting multiple values calls `handleAdd`
|
||||
* once per value within a single event, before React re-renders — reading
|
||||
* the `items` state there would make every call see the same stale array
|
||||
* and each add overwrite the previous one (only the last pasted email
|
||||
* survives). All reads and writes go through the ref so consecutive adds
|
||||
* compose; `commitItems` keeps state and ref in lockstep.
|
||||
*/
|
||||
const itemsRef = React.useRef<TagItem[]>(items)
|
||||
|
||||
const commitItems = React.useCallback((next: TagItem[]) => {
|
||||
itemsRef.current = next
|
||||
setItems(next)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Reconcile internal `items` with the consumer's `value` when the latter
|
||||
* changes externally (programmatic clear, partial-failure reseed, etc.).
|
||||
@@ -589,25 +604,25 @@ function ChipModalEmailsControl({
|
||||
* `items` already match `value` and this is a no-op.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
setItems((prev) => {
|
||||
const prevValid = prev.filter((item) => item.isValid).map((item) => item.value)
|
||||
if (prevValid.length === value.length && prevValid.every((v, idx) => v === value[idx])) {
|
||||
return prev
|
||||
}
|
||||
return value.map((v) => ({ value: v, isValid: true }))
|
||||
})
|
||||
const prevValid = itemsRef.current.filter((item) => item.isValid).map((item) => item.value)
|
||||
if (prevValid.length === value.length && prevValid.every((v, idx) => v === value[idx])) {
|
||||
return
|
||||
}
|
||||
itemsRef.current = value.map((v) => ({ value: v, isValid: true }))
|
||||
setItems(itemsRef.current)
|
||||
}, [value])
|
||||
|
||||
const handleAdd = React.useCallback(
|
||||
(raw: string): boolean => {
|
||||
const email = raw.trim().toLowerCase()
|
||||
if (!email) return false
|
||||
if (items.some((item) => item.value === email)) return false
|
||||
const current = itemsRef.current
|
||||
if (current.some((item) => item.value === email)) return false
|
||||
|
||||
const formatCheck = quickValidateEmail(email)
|
||||
if (!formatCheck.isValid) {
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
commitItems([
|
||||
...current,
|
||||
{ value: email, isValid: false, error: formatCheck.reason ?? 'Invalid email format' },
|
||||
])
|
||||
return false
|
||||
@@ -615,28 +630,29 @@ function ChipModalEmailsControl({
|
||||
|
||||
const reason = validate?.(email)
|
||||
if (reason) {
|
||||
setItems((prev) => [...prev, { value: email, isValid: false, error: reason }])
|
||||
commitItems([...current, { value: email, isValid: false, error: reason }])
|
||||
return false
|
||||
}
|
||||
|
||||
const next = [...items, { value: email, isValid: true }]
|
||||
setItems(next)
|
||||
const next = [...current, { value: email, isValid: true }]
|
||||
commitItems(next)
|
||||
onChange(next.filter((item) => item.isValid).map((item) => item.value))
|
||||
return true
|
||||
},
|
||||
[items, validate, onChange]
|
||||
[validate, onChange, commitItems]
|
||||
)
|
||||
|
||||
const handleRemove = React.useCallback(
|
||||
(_removed: string, index: number) => {
|
||||
const wasValid = items[index]?.isValid ?? false
|
||||
const next = items.filter((_, i) => i !== index)
|
||||
setItems(next)
|
||||
const current = itemsRef.current
|
||||
const wasValid = current[index]?.isValid ?? false
|
||||
const next = current.filter((_, i) => i !== index)
|
||||
commitItems(next)
|
||||
if (wasValid) {
|
||||
onChange(next.filter((item) => item.isValid).map((item) => item.value))
|
||||
}
|
||||
},
|
||||
[items, onChange]
|
||||
[onChange, commitItems]
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -56,6 +56,18 @@ function hasOpenFloatingLayer() {
|
||||
return Boolean(document.querySelector('[data-radix-popper-content-wrapper] [data-state="open"]'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current subtree renders inside a `ModalContent`.
|
||||
*
|
||||
* Floating EMCN controls (e.g. `ChipDropdown`) read this to switch their
|
||||
* Radix popper to modal behavior. A non-modal popper portaled to `body`
|
||||
* underneath a modal dialog inherits the dialog's `pointer-events: none`
|
||||
* body lock and its outside-scroll lock, leaving the popper unclickable and
|
||||
* unscrollable; a modal popper pauses the dialog's focus trap and carries
|
||||
* its own scroll allowance.
|
||||
*/
|
||||
const InsideModalContext = React.createContext(false)
|
||||
|
||||
/**
|
||||
* Root modal component. Manages open state.
|
||||
*/
|
||||
@@ -244,7 +256,7 @@ const ModalContent = React.forwardRef<
|
||||
{srTitle ? (
|
||||
<DialogPrimitive.Title className='sr-only'>{srTitle}</DialogPrimitive.Title>
|
||||
) : null}
|
||||
{children}
|
||||
<InsideModalContext.Provider value={true}>{children}</InsideModalContext.Provider>
|
||||
</DialogPrimitive.Content>
|
||||
</div>
|
||||
</ModalPortal>
|
||||
@@ -472,6 +484,7 @@ const ModalFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDi
|
||||
ModalFooter.displayName = 'ModalFooter'
|
||||
|
||||
export {
|
||||
InsideModalContext,
|
||||
Modal,
|
||||
ModalTrigger,
|
||||
ModalContent,
|
||||
|
||||
@@ -334,26 +334,22 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
|
||||
[inputValue, triggerKeys, onAdd, items, onRemove]
|
||||
)
|
||||
|
||||
/**
|
||||
* Pasted values are committed through `onAdd` exactly like typing + Enter:
|
||||
* consumers render rejected values as flagged invalid chips, so nothing is
|
||||
* re-staged into the input afterwards — doing so would display the same
|
||||
* value twice (the invalid chip plus the raw text in the typing buffer).
|
||||
*/
|
||||
const handlePaste = React.useCallback(
|
||||
(e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
e.preventDefault()
|
||||
const pastedText = e.clipboardData.getData('text')
|
||||
const pastedValues = pastedText.split(/[\s,;]+/).filter(Boolean)
|
||||
|
||||
let addedCount = 0
|
||||
pastedValues.forEach((value) => {
|
||||
if (onAdd(value.trim())) {
|
||||
addedCount++
|
||||
}
|
||||
onAdd(value.trim())
|
||||
})
|
||||
|
||||
if (addedCount === 0 && pastedValues.length === 1) {
|
||||
const newValue = inputValue + pastedValues[0]
|
||||
setInputValue(newValue)
|
||||
onInputChange?.(newValue)
|
||||
}
|
||||
},
|
||||
[onAdd, inputValue, onInputChange]
|
||||
[onAdd]
|
||||
)
|
||||
|
||||
const handleBlur = React.useCallback(() => {
|
||||
|
||||
@@ -185,27 +185,33 @@ export async function sendInvitationEmail(
|
||||
const inviteUrl = `${getBaseUrl()}/invite/${input.invitationId}?token=${input.token}`
|
||||
|
||||
if (input.kind === 'workspace') {
|
||||
const primaryGrant = input.grants[0]
|
||||
if (!primaryGrant) {
|
||||
if (input.grants.length === 0) {
|
||||
return { success: false, error: 'Workspace invitation is missing a workspace grant' }
|
||||
}
|
||||
|
||||
const [workspaceRow] = await db
|
||||
.select({ name: workspace.name })
|
||||
const grantWorkspaceIds = input.grants.map((grant) => grant.workspaceId)
|
||||
const workspaceRows = await db
|
||||
.select({ id: workspace.id, name: workspace.name })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, primaryGrant.workspaceId))
|
||||
.limit(1)
|
||||
.where(inArray(workspace.id, grantWorkspaceIds))
|
||||
const workspaceNames = grantWorkspaceIds.map(
|
||||
(id) => workspaceRows.find((row) => row.id === id)?.name || 'a workspace'
|
||||
)
|
||||
|
||||
const workspaceName = workspaceRow?.name || 'a workspace'
|
||||
const emailHtml = await renderWorkspaceInvitationEmail(
|
||||
input.inviterName,
|
||||
workspaceName,
|
||||
workspaceNames,
|
||||
inviteUrl
|
||||
)
|
||||
|
||||
const subject =
|
||||
workspaceNames.length === 1
|
||||
? `You've been invited to join "${workspaceNames[0]}" on Sim`
|
||||
: `You've been invited to join ${workspaceNames.length} workspaces on Sim`
|
||||
|
||||
const result = await sendEmail({
|
||||
to: input.email,
|
||||
subject: `You've been invited to join "${workspaceName}" on Sim`,
|
||||
subject,
|
||||
html: emailHtml,
|
||||
from: getFromEmailAddress(),
|
||||
emailType: 'transactional',
|
||||
|
||||
Reference in New Issue
Block a user