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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+356
View File
@@ -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,
})
}