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,356 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user