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 { 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 { const [row] = await db .select({ count: sql`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 { 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 { 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 { 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 { 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, }) }