Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (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 / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

735 lines
24 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 { isOrgAdminRole } from '@sim/platform-authz/workspace'
import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { normalizeEmail } from '@sim/utils/string'
import { and, asc, eq, inArray, ne, sql } from 'drizzle-orm'
import {
getEmailSubject,
renderBatchInvitationEmail,
renderInvitationEmail,
renderWorkspaceAddedEmail,
renderWorkspaceInvitationEmail,
} from '@/components/emails'
import { getBaseUrl } from '@/lib/core/utils/urls'
import type { DbOrTx } from '@/lib/db/types'
import { computeInvitationExpiry, lockInvitationForMutation } from '@/lib/invitations/core'
import { acquireInvitationMutationLocks } from '@/lib/invitations/locks'
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
/**
* Runs after the canonical invitation/workspace advisory locks are held and
* the live organization scope has been resolved, but before any invitation
* write. Callers use this DB-only hook to acquire organization/user locks
* and re-authorize stale preflight decisions.
*/
validateLockedContext?: (context: {
tx: DbOrTx
organizationId: string | null
workspaceIds: string[]
}) => Promise<void>
}
export interface CreatePendingInvitationResult {
invitationId: string
token: string
expiresAt: Date
/**
* False when the grants were merged into an invitation that was already
* pending for this (email, organization). Callers compensating for a failed
* send must revert only the added grants in that case — cancelling would
* destroy an unrelated, still-valid invitation.
*/
created: boolean
/** Workspaces this call added; empty when every grant was already present. */
addedWorkspaceIds: string[]
/** Every workspace the invitation now grants, oldest grant first. */
grants: WorkspaceGrantInput[]
/**
* Optimistic revision for failed-send compensation. A workspace move,
* acceptance, PATCH, or other later mutation changes this timestamp, causing
* compensation to skip rather than undo newer state.
*/
mutationUpdatedAt: Date
/** Scope paired with the revision so a migrated pending invite is never undone. */
mutationOrganizationId: string | null
}
/**
* Partial unique index on `invitation (email, organization_id) WHERE status =
* 'pending' AND organization_id IS NOT NULL` — one pending invitation per
* person per organization, which is what makes coalescing mandatory rather
* than optional.
*/
export const PENDING_INVITATION_UNIQUE_INDEX = 'invitation_pending_email_org_unique'
/**
* Raised when the granted workspaces changed organization between the
* pre-lock lookup and the lock itself, so the invitation that would be merged
* into was never covered by the acquired locks. Retrying re-resolves the
* organization and locks the right row.
*/
class InvitationScopeChangedError extends Error {
constructor() {
super('Invitation organization scope changed while acquiring locks')
this.name = 'InvitationScopeChangedError'
}
}
function isPendingInvitationConflict(error: unknown): boolean {
return (
error instanceof InvitationScopeChangedError ||
(getPostgresErrorCode(error) === '23505' &&
getPostgresConstraintName(error) === PENDING_INVITATION_UNIQUE_INDEX)
)
}
/**
* The organization an invitation is stamped with. Workspace invitations derive
* it from the granted workspaces' live organization so a workspace that moved
* since the inviter loaded the page is stamped with where it actually lives.
*/
async function resolveInvitationOrganizationId(
executor: DbOrTx,
input: CreatePendingInvitationInput,
workspaceIds: string[]
): Promise<string | null> {
if (input.kind !== 'workspace' || workspaceIds.length === 0) return input.organizationId
const currentScopes = await executor
.select({ organizationId: workspace.organizationId })
.from(workspace)
.where(inArray(workspace.id, workspaceIds))
const uniqueScopes = [...new Set(currentScopes.map((row) => row.organizationId))]
return uniqueScopes.length === 1 ? uniqueScopes[0] : input.organizationId
}
async function findPendingOrganizationInvitation(
executor: DbOrTx,
organizationId: string,
email: string
) {
const [row] = await executor
.select({
id: invitation.id,
token: invitation.token,
expiresAt: invitation.expiresAt,
role: invitation.role,
membershipIntent: invitation.membershipIntent,
updatedAt: invitation.updatedAt,
organizationId: invitation.organizationId,
})
.from(invitation)
.where(
and(
eq(invitation.organizationId, organizationId),
eq(invitation.email, email),
eq(invitation.status, 'pending')
)
)
.limit(1)
return row ?? null
}
function describeInvitationStanding(
membershipIntent: InvitationMembershipIntent,
role: string
): string {
if (membershipIntent === 'external') return 'an external collaborator'
return isOrgAdminRole(role) ? 'an organization admin' : 'an organization member'
}
/**
* Thrown when new workspaces would merge into a pending invitation that grants
* a different standing. Adding workspaces must never quietly re-decide whether
* the invitee takes a seat or becomes an admin, and neither silent outcome is
* right — the old choice ignores what the inviter just asked for, the new one
* rewrites an invitation somebody else may have sent. The inviter resolves it.
*/
export class ConflictingPendingInvitationError extends Error {
constructor(params: {
email: string
existing: { membershipIntent: InvitationMembershipIntent; role: string }
requested: { membershipIntent: InvitationMembershipIntent; role: string }
}) {
super(
`${params.email} already has a pending invitation as ${describeInvitationStanding(
params.existing.membershipIntent,
params.existing.role
)}. Cancel it before inviting them as ${describeInvitationStanding(
params.requested.membershipIntent,
params.requested.role
)}.`
)
this.name = 'ConflictingPendingInvitationError'
}
}
/**
* Thrown when an invitation carries no workspace grants. Every invitation names
* at least one workspace, so accepting always lands the invitee somewhere
* concrete and the email can say what they are being given. Admins would derive
* access to every organization workspace anyway, but a grantless admin invite
* still reads as "join this organization" with nothing to open, so it is
* rejected too. Enforced here so every creation path — routes, admin tooling,
* future callers — hits the same rule.
*/
export class GrantlessInvitationError extends Error {
constructor() {
super(
'Invitations must include at least one workspace so the invitee has a workspace to land in.'
)
this.name = 'GrantlessInvitationError'
}
}
/**
* Creates a pending invitation, or extends the one already pending for this
* (email, organization) with the workspaces it does not cover yet.
*
* Coalescing is required, not a convenience: a person can hold at most one
* pending invitation per organization, so inviting them to a second workspace
* has to become another grant on the same invitation. It also gives the
* invitee one link that grants everything they were invited to, instead of a
* queue of invitations to accept one at a time.
*
* Invitations with no organization (personal-workspace invites) are never
* coalesced — they are scoped to their inviter, and acceptance converts *that*
* inviter's plan, so two inviters' invites must stay independent.
*/
export async function createPendingInvitation(
input: CreatePendingInvitationInput
): Promise<CreatePendingInvitationResult> {
if (input.grants.length === 0) {
throw new GrantlessInvitationError()
}
try {
return await createOrExtendPendingInvitation(input)
} catch (error) {
if (!isPendingInvitationConflict(error)) throw error
/**
* A concurrent invite created the pending row, or moved a granted
* workspace, between the pre-lock lookup and the insert. The retry sees
* the committed row and merges into it.
*/
return createOrExtendPendingInvitation(input)
}
}
async function createOrExtendPendingInvitation(
input: CreatePendingInvitationInput
): Promise<CreatePendingInvitationResult> {
const email = normalizeEmail(input.email)
const workspaceIds = input.grants.map((grant) => grant.workspaceId)
/**
* Resolved before the transaction so the invitation being merged into can
* join the same sorted lock acquisition. Advisory locks must be taken in one
* call — invitation keys sort before workspace keys, matching the order
* acceptance takes them in, so the two paths cannot deadlock.
*/
const scopeOrganizationId = await resolveInvitationOrganizationId(db, input, workspaceIds)
const knownPendingId = scopeOrganizationId
? (await findPendingOrganizationInvitation(db, scopeOrganizationId, email))?.id
: undefined
const newInvitationId = generateId()
const token = generateId()
const expiresAt = input.expiresAt ?? computeInvitationExpiry()
const now = new Date()
return db.transaction(async (tx) => {
await acquireInvitationMutationLocks(tx, {
invitationIds: knownPendingId ? [knownPendingId, newInvitationId] : [newInvitationId],
workspaceIds,
})
const organizationId = await resolveInvitationOrganizationId(tx, input, workspaceIds)
await input.validateLockedContext?.({ tx, organizationId, workspaceIds })
const existing = organizationId
? await findPendingOrganizationInvitation(tx, organizationId, email)
: null
if (existing && existing.id !== knownPendingId) {
throw new InvitationScopeChangedError()
}
if (existing) {
return extendPendingInvitation(tx, { existing, input, expiresAt, now })
}
await tx.insert(invitation).values({
id: newInvitationId,
kind: input.kind,
email,
inviterId: input.inviterId,
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: newInvitationId,
workspaceId: grant.workspaceId,
permission: grant.permission,
createdAt: now,
updatedAt: now,
})
}
return {
invitationId: newInvitationId,
token,
expiresAt,
created: true,
addedWorkspaceIds: workspaceIds,
grants: input.grants,
mutationUpdatedAt: now,
mutationOrganizationId: organizationId,
}
})
}
/**
* Adds the grants an already-pending invitation is missing. Its kind, role, and
* membership intent are left alone — they decide what acceptance does to the
* invitee's organization membership, and adding a workspace is not consent to
* change that — so a request for a different standing is rejected above rather
* than resolved silently. Permissions on grants that already exist are likewise
* untouched, so an invite can never downgrade access already promised.
*/
async function extendPendingInvitation(
tx: DbOrTx,
params: {
existing: {
id: string
token: string
expiresAt: Date
role: string
membershipIntent: InvitationMembershipIntent
updatedAt: Date
organizationId: string | null
}
input: CreatePendingInvitationInput
expiresAt: Date
now: Date
}
): Promise<CreatePendingInvitationResult> {
const { existing, input, expiresAt, now } = params
const requestedIntent = input.membershipIntent ?? 'internal'
if (
existing.membershipIntent !== requestedIntent ||
isOrgAdminRole(existing.role) !== isOrgAdminRole(input.role)
) {
throw new ConflictingPendingInvitationError({
email: normalizeEmail(input.email),
existing: { membershipIntent: existing.membershipIntent, role: existing.role },
requested: { membershipIntent: requestedIntent, role: input.role },
})
}
const existingGrants = await tx
.select({
workspaceId: invitationWorkspaceGrant.workspaceId,
permission: invitationWorkspaceGrant.permission,
})
.from(invitationWorkspaceGrant)
.where(eq(invitationWorkspaceGrant.invitationId, existing.id))
.orderBy(asc(invitationWorkspaceGrant.createdAt), asc(invitationWorkspaceGrant.id))
const grantedWorkspaceIds = new Set(existingGrants.map((grant) => grant.workspaceId))
const addedGrants = input.grants.filter((grant) => !grantedWorkspaceIds.has(grant.workspaceId))
for (const grant of addedGrants) {
await tx.insert(invitationWorkspaceGrant).values({
id: generateId(),
invitationId: existing.id,
workspaceId: grant.workspaceId,
permission: grant.permission,
createdAt: now,
updatedAt: now,
})
}
/**
* Extending expiry keeps a late-added workspace from riding an almost-dead
* link. It only ever moves the deadline out, so it needs no compensation
* when the send fails.
*/
const nextExpiresAt = existing.expiresAt > expiresAt ? existing.expiresAt : expiresAt
if (addedGrants.length > 0) {
await tx
.update(invitation)
.set({ expiresAt: nextExpiresAt, updatedAt: now })
.where(eq(invitation.id, existing.id))
}
return {
invitationId: existing.id,
token: existing.token,
expiresAt: nextExpiresAt,
created: false,
addedWorkspaceIds: addedGrants.map((grant) => grant.workspaceId),
grants: [...existingGrants, ...addedGrants],
mutationUpdatedAt: addedGrants.length > 0 ? now : existing.updatedAt,
mutationOrganizationId: existing.organizationId,
}
}
/**
* Undoes the grants a failed send added to a pre-existing invitation. The
* invitation itself survives — it was valid before this call and the
* workspaces it already covered are unaffected.
*/
export async function revertPendingInvitationGrants(params: {
invitationId: string
workspaceIds: string[]
expectedUpdatedAt: Date
expectedOrganizationId: string | null
}): Promise<boolean> {
if (params.workspaceIds.length === 0) return false
return db.transaction(async (tx) => {
const locked = await lockInvitationForMutation(tx, params.invitationId)
if (
!locked ||
locked.status !== 'pending' ||
locked.organizationId !== params.expectedOrganizationId ||
locked.updatedAt.getTime() !== params.expectedUpdatedAt.getTime()
) {
return false
}
const now = new Date()
const claimed = await tx
.update(invitation)
.set({ updatedAt: now })
.where(
and(
eq(invitation.id, params.invitationId),
eq(invitation.status, 'pending'),
eq(invitation.updatedAt, params.expectedUpdatedAt),
params.expectedOrganizationId
? eq(invitation.organizationId, params.expectedOrganizationId)
: sql`${invitation.organizationId} IS NULL`
)
)
.returning({ id: invitation.id })
if (claimed.length === 0) return false
await tx
.delete(invitationWorkspaceGrant)
.where(
and(
eq(invitationWorkspaceGrant.invitationId, params.invitationId),
inArray(invitationWorkspaceGrant.workspaceId, params.workspaceIds)
)
)
return true
})
}
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
}
/**
* Workspaces this email already holds a pending grant for, across every
* pending invitation. Callers use it to drop workspaces from a new invite
* rather than rejecting the whole thing.
*/
export async function findPendingGrantWorkspaceIds(params: {
workspaceIds: string[]
email: string
}): Promise<Set<string>> {
if (params.workspaceIds.length === 0) return new Set()
const rows = await db
.select({ workspaceId: invitationWorkspaceGrant.workspaceId })
.from(invitationWorkspaceGrant)
.innerJoin(invitation, eq(invitation.id, invitationWorkspaceGrant.invitationId))
.where(
and(
inArray(invitationWorkspaceGrant.workspaceId, params.workspaceIds),
eq(invitation.email, normalizeEmail(params.email)),
eq(invitation.status, 'pending')
)
)
return new Set(rows.map((row) => row.workspaceId))
}
export async function cancelPendingInvitation(
invitationId: string,
guard?: {
expectedUpdatedAt: Date
expectedOrganizationId: string | null
}
): Promise<boolean> {
return db.transaction(async (tx) => {
const locked = await lockInvitationForMutation(tx, invitationId)
if (!locked || locked.status !== 'pending') return false
if (
guard &&
(locked.organizationId !== guard.expectedOrganizationId ||
locked.updatedAt.getTime() !== guard.expectedUpdatedAt.getTime())
) {
return false
}
const cancelled = await tx
.update(invitation)
.set({ status: 'cancelled', updatedAt: new Date() })
.where(
and(
eq(invitation.id, invitationId),
eq(invitation.status, 'pending'),
...(guard
? [
eq(invitation.updatedAt, guard.expectedUpdatedAt),
guard.expectedOrganizationId
? eq(invitation.organizationId, guard.expectedOrganizationId)
: sql`${invitation.organizationId} IS NULL`,
]
: [])
)
)
.returning({ id: invitation.id })
return cancelled.length > 0
})
}
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}`
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,
})
}