d25d482dc2
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
357 lines
9.9 KiB
TypeScript
357 lines
9.9 KiB
TypeScript
import { db } from '@sim/db'
|
|
import {
|
|
type InvitationKind,
|
|
type InvitationMembershipIntent,
|
|
invitation,
|
|
invitationWorkspaceGrant,
|
|
organization,
|
|
workspace,
|
|
} from '@sim/db/schema'
|
|
import { createLogger } from '@sim/logger'
|
|
import { generateId } from '@sim/utils/id'
|
|
import { normalizeEmail } from '@sim/utils/string'
|
|
import { and, eq, inArray, ne, sql } from 'drizzle-orm'
|
|
import {
|
|
getEmailSubject,
|
|
renderBatchInvitationEmail,
|
|
renderInvitationEmail,
|
|
renderWorkspaceAddedEmail,
|
|
renderWorkspaceInvitationEmail,
|
|
} from '@/components/emails'
|
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
|
import { computeInvitationExpiry } from '@/lib/invitations/core'
|
|
import { sendEmail } from '@/lib/messaging/email/mailer'
|
|
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
|
|
import { getBrandConfig } from '@/ee/whitelabeling'
|
|
|
|
const logger = createLogger('InvitationSend')
|
|
|
|
interface WorkspaceGrantInput {
|
|
workspaceId: string
|
|
permission: 'admin' | 'write' | 'read'
|
|
}
|
|
|
|
export interface CreatePendingInvitationInput {
|
|
kind: InvitationKind
|
|
email: string
|
|
inviterId: string
|
|
organizationId: string | null
|
|
membershipIntent?: InvitationMembershipIntent
|
|
role: 'admin' | 'member'
|
|
grants: WorkspaceGrantInput[]
|
|
expiresAt?: Date
|
|
}
|
|
|
|
export interface CreatePendingInvitationResult {
|
|
invitationId: string
|
|
token: string
|
|
expiresAt: Date
|
|
}
|
|
|
|
export async function createPendingInvitation(
|
|
input: CreatePendingInvitationInput
|
|
): Promise<CreatePendingInvitationResult> {
|
|
const invitationId = generateId()
|
|
const token = generateId()
|
|
const expiresAt = input.expiresAt ?? computeInvitationExpiry()
|
|
const now = new Date()
|
|
|
|
await db.transaction(async (tx) => {
|
|
await tx.insert(invitation).values({
|
|
id: invitationId,
|
|
kind: input.kind,
|
|
email: normalizeEmail(input.email),
|
|
inviterId: input.inviterId,
|
|
organizationId: input.organizationId,
|
|
membershipIntent: input.membershipIntent ?? 'internal',
|
|
role: input.role,
|
|
status: 'pending',
|
|
token,
|
|
expiresAt,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
})
|
|
|
|
for (const grant of input.grants) {
|
|
await tx.insert(invitationWorkspaceGrant).values({
|
|
id: generateId(),
|
|
invitationId,
|
|
workspaceId: grant.workspaceId,
|
|
permission: grant.permission,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
})
|
|
}
|
|
})
|
|
|
|
return { invitationId, token, expiresAt }
|
|
}
|
|
|
|
async function countPendingInvitationsForOrganization(organizationId: string): Promise<number> {
|
|
const [row] = await db
|
|
.select({ count: sql<number>`count(*)::int` })
|
|
.from(invitation)
|
|
.where(
|
|
and(
|
|
eq(invitation.organizationId, organizationId),
|
|
eq(invitation.status, 'pending'),
|
|
ne(invitation.membershipIntent, 'external')
|
|
)
|
|
)
|
|
return row?.count ?? 0
|
|
}
|
|
|
|
async function findPendingInvitationByOrgEmail(params: {
|
|
organizationId: string | null
|
|
email: string
|
|
}) {
|
|
const normalized = normalizeEmail(params.email)
|
|
|
|
if (params.organizationId) {
|
|
const [row] = await db
|
|
.select()
|
|
.from(invitation)
|
|
.where(
|
|
and(
|
|
eq(invitation.organizationId, params.organizationId),
|
|
eq(invitation.email, normalized),
|
|
eq(invitation.status, 'pending')
|
|
)
|
|
)
|
|
.limit(1)
|
|
return row ?? null
|
|
}
|
|
|
|
const [row] = await db
|
|
.select()
|
|
.from(invitation)
|
|
.where(
|
|
and(
|
|
sql`${invitation.organizationId} IS NULL`,
|
|
eq(invitation.email, normalized),
|
|
eq(invitation.status, 'pending')
|
|
)
|
|
)
|
|
.limit(1)
|
|
return row ?? null
|
|
}
|
|
|
|
export async function findPendingGrantForWorkspaceEmail(params: {
|
|
workspaceId: string
|
|
email: string
|
|
}) {
|
|
const normalized = normalizeEmail(params.email)
|
|
const [row] = await db
|
|
.select({
|
|
invitationId: invitation.id,
|
|
grantId: invitationWorkspaceGrant.id,
|
|
})
|
|
.from(invitationWorkspaceGrant)
|
|
.innerJoin(invitation, eq(invitation.id, invitationWorkspaceGrant.invitationId))
|
|
.where(
|
|
and(
|
|
eq(invitationWorkspaceGrant.workspaceId, params.workspaceId),
|
|
eq(invitation.email, normalized),
|
|
eq(invitation.status, 'pending')
|
|
)
|
|
)
|
|
.limit(1)
|
|
return row ?? null
|
|
}
|
|
|
|
export async function cancelPendingInvitation(invitationId: string): Promise<void> {
|
|
await db
|
|
.update(invitation)
|
|
.set({ status: 'cancelled', updatedAt: new Date() })
|
|
.where(and(eq(invitation.id, invitationId), eq(invitation.status, 'pending')))
|
|
}
|
|
|
|
export interface SendInvitationEmailInput {
|
|
invitationId: string
|
|
token: string
|
|
kind: InvitationKind
|
|
email: string
|
|
inviterName: string
|
|
organizationId: string | null
|
|
organizationRole: 'admin' | 'member'
|
|
grants: WorkspaceGrantInput[]
|
|
}
|
|
|
|
export interface SendInvitationEmailResult {
|
|
success: boolean
|
|
error?: string
|
|
}
|
|
|
|
export async function sendInvitationEmail(
|
|
input: SendInvitationEmailInput
|
|
): Promise<SendInvitationEmailResult> {
|
|
const inviteUrl = `${getBaseUrl()}/invite/${input.invitationId}?token=${input.token}`
|
|
|
|
if (input.kind === 'workspace') {
|
|
if (input.grants.length === 0) {
|
|
return { success: false, error: 'Workspace invitation is missing a workspace grant' }
|
|
}
|
|
|
|
const grantWorkspaceIds = input.grants.map((grant) => grant.workspaceId)
|
|
const workspaceRows = await db
|
|
.select({ id: workspace.id, name: workspace.name })
|
|
.from(workspace)
|
|
.where(inArray(workspace.id, grantWorkspaceIds))
|
|
const workspaceNames = grantWorkspaceIds.map(
|
|
(id) => workspaceRows.find((row) => row.id === id)?.name || 'a workspace'
|
|
)
|
|
|
|
const emailHtml = await renderWorkspaceInvitationEmail(
|
|
input.inviterName,
|
|
workspaceNames,
|
|
inviteUrl
|
|
)
|
|
|
|
const brandName = getBrandConfig().name
|
|
const subject =
|
|
workspaceNames.length === 1
|
|
? `You've been invited to join "${workspaceNames[0]}" on ${brandName}`
|
|
: `You've been invited to join ${workspaceNames.length} workspaces on ${brandName}`
|
|
|
|
const result = await sendEmail({
|
|
to: input.email,
|
|
subject,
|
|
html: emailHtml,
|
|
from: getFromEmailAddress(),
|
|
emailType: 'transactional',
|
|
})
|
|
if (!result.success) {
|
|
return { success: false, error: result.message }
|
|
}
|
|
return { success: true }
|
|
}
|
|
|
|
if (!input.organizationId) {
|
|
return { success: false, error: 'Organization invitation missing organization id' }
|
|
}
|
|
|
|
const [orgRow] = await db
|
|
.select({ name: organization.name })
|
|
.from(organization)
|
|
.where(eq(organization.id, input.organizationId))
|
|
.limit(1)
|
|
const organizationName = orgRow?.name || 'organization'
|
|
|
|
if (input.grants.length > 0) {
|
|
const workspaceIds = input.grants.map((grant) => grant.workspaceId)
|
|
const workspaceRows = await db
|
|
.select({ id: workspace.id, name: workspace.name })
|
|
.from(workspace)
|
|
.where(inArray(workspace.id, workspaceIds))
|
|
|
|
const grantPayloads = input.grants.map((grant) => ({
|
|
workspaceId: grant.workspaceId,
|
|
workspaceName:
|
|
workspaceRows.find((row) => row.id === grant.workspaceId)?.name || 'Unknown Workspace',
|
|
permission: grant.permission,
|
|
}))
|
|
|
|
const emailHtml = await renderBatchInvitationEmail(
|
|
input.inviterName,
|
|
organizationName,
|
|
input.organizationRole,
|
|
grantPayloads,
|
|
inviteUrl
|
|
)
|
|
|
|
const result = await sendEmail({
|
|
to: input.email,
|
|
subject: getEmailSubject('batch-invitation'),
|
|
html: emailHtml,
|
|
emailType: 'transactional',
|
|
})
|
|
if (!result.success) {
|
|
return { success: false, error: result.message }
|
|
}
|
|
return { success: true }
|
|
}
|
|
|
|
const emailHtml = await renderInvitationEmail(input.inviterName, organizationName, inviteUrl)
|
|
const result = await sendEmail({
|
|
to: input.email,
|
|
subject: getEmailSubject('invitation'),
|
|
html: emailHtml,
|
|
emailType: 'transactional',
|
|
})
|
|
if (!result.success) {
|
|
return { success: false, error: result.message }
|
|
}
|
|
return { success: true }
|
|
}
|
|
|
|
export interface SendWorkspaceAddedEmailInput {
|
|
email: string
|
|
inviterName: string
|
|
workspaceId: string
|
|
workspaceName: string
|
|
}
|
|
|
|
/**
|
|
* Lightweight notification sent when an existing organization member is added
|
|
* directly to a workspace. Unlike an invitation email, this links straight to
|
|
* the workspace and has no acceptance step.
|
|
*/
|
|
export async function sendWorkspaceAddedEmail(
|
|
input: SendWorkspaceAddedEmailInput
|
|
): Promise<SendInvitationEmailResult> {
|
|
const workspaceLink = `${getBaseUrl()}/workspace/${input.workspaceId}/home`
|
|
const emailHtml = await renderWorkspaceAddedEmail(
|
|
input.inviterName,
|
|
input.workspaceName,
|
|
workspaceLink
|
|
)
|
|
|
|
const result = await sendEmail({
|
|
to: input.email,
|
|
subject: getEmailSubject('workspace-added'),
|
|
html: emailHtml,
|
|
from: getFromEmailAddress(),
|
|
emailType: 'transactional',
|
|
})
|
|
if (!result.success) {
|
|
return { success: false, error: result.message }
|
|
}
|
|
return { success: true }
|
|
}
|
|
|
|
export async function prepareInvitationResend(params: {
|
|
invitationId: string
|
|
rotateToken?: boolean
|
|
currentToken: string
|
|
}): Promise<{ tokenForEmail: string; nextExpiresAt: Date; nextToken: string | null }> {
|
|
const nextExpiresAt = computeInvitationExpiry()
|
|
const nextToken = params.rotateToken ? generateId() : null
|
|
const tokenForEmail = nextToken ?? params.currentToken
|
|
return { tokenForEmail, nextExpiresAt, nextToken }
|
|
}
|
|
|
|
export async function persistInvitationResend(params: {
|
|
invitationId: string
|
|
nextToken: string | null
|
|
nextExpiresAt: Date
|
|
}): Promise<void> {
|
|
const [row] = await db
|
|
.update(invitation)
|
|
.set({
|
|
expiresAt: params.nextExpiresAt,
|
|
updatedAt: new Date(),
|
|
...(params.nextToken ? { token: params.nextToken } : {}),
|
|
})
|
|
.where(and(eq(invitation.id, params.invitationId), eq(invitation.status, 'pending')))
|
|
.returning({ id: invitation.id })
|
|
|
|
if (!row) {
|
|
throw new Error(`Invitation ${params.invitationId} not found or no longer pending`)
|
|
}
|
|
|
|
logger.info('Persisted invitation resend', {
|
|
invitationId: params.invitationId,
|
|
rotated: !!params.nextToken,
|
|
})
|
|
}
|